diff --git a/CHANGELOG.md b/CHANGELOG.md index d6df9180c..d9b856591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Manage Guestbooks page integration, including: + - a guestbooks table with sorting, enable/disable actions, preview, and per-guestbook response download + - Create Guestbook and Download All Responses actions + - the Create Guestbook page + - a checkbox for including guestbooks from parent collections - Dataset Templates UI integration, including create/edit flows, previews, and skeleton states. - Dataset Page: added a sidebar to show dataset reviews diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index 6690be4a1..c5a334b75 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -52,11 +52,11 @@ "editCollection": { "edit": "Edit", "generalInfo": "General Information", + "datasetGuestbooks": "Dataset Guestbooks", "themeAndWidgets": "Theme + Widgets", "permissions": "Permissions", "groups": "Groups", "datasetTemplates": "Dataset Templates", - "datasetGuestbooks": "Dataset Guestbooks", "featuredDataverses": "Featured Dataverses", "deleteCollection": "Delete Collection" }, diff --git a/public/locales/en/guestbooks.json b/public/locales/en/guestbooks.json index 797332e78..447e5953b 100644 --- a/public/locales/en/guestbooks.json +++ b/public/locales/en/guestbooks.json @@ -1,7 +1,27 @@ { "title": "Dataset Guestbooks", + "filters": { + "includeFromParent": "Include Guestbooks from {{parent}}" + }, + "actions": { + "create": "Create Dataset Guestbook", + "enable": "Enable", + "disable": "Disable", + "downloadAllResponses": "Download All Responses", + "view": "View", + "copy": "Copy", + "edit": "Edit", + "downloadResponses": "Download responses", + "viewResponses": "View Responses" + }, "errors": { - "getGuestbook": "Something went wrong getting the guestbook. Try again later." + "getGuestbook": "Something went wrong getting the guestbook. Try again later.", + "toggleEnabled": "Something went wrong updating the guestbook status. Try again later.", + "downloadResponses": "Something went wrong downloading guestbook responses. Try again later." + }, + "alerts": { + "statusUpdated": "The guestbook status has been updated.", + "downloadStarted": "Your download has started." }, "preview": { "title": "Preview Guestbook", @@ -9,13 +29,41 @@ "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" }, + "table": { + "name": "Guestbook Name", + "created": "Created", + "usage": "Usage", + "responses": "Responses", + "action": "Action", + "guestbookCreatedAt": "Guestbook created at {{alias}}" + }, + "emptyState": { + "whyTitle": "Why Use Guestbooks?", + "whyBullets": [ + "Guestbooks allow you to collect data about who is downloading the files from your datasets. You can decide to collect account information (username, given name & last name, affiliation, etc.) as well as create custom questions (e.g., What do you plan to use this data for?).", + "You can download the data collected from the enabled guestbooks to be able to store it outside of Dataverse." + ], + "howTitle": "How To Use Guestbooks", + "howBullets": [ + "A guestbook can be used for multiple datasets but only one guestbook can be used for a dataset.", + "Custom questions can have free form text answers or have a user select an answer from several options." + ], + "footer": "To get started, click on Edit Collection in this Dataverse and then select General Information. To learn more about guestbooks, visit the Dataset Guestbook section of the User Guide." + }, "create": { + "title": "Create Dataset Guestbook", + "info": "To learn more about guestbooks, visit the Dataset Guestbook section of the User Guide.", "fields": { + "name": { + "label": "Guestbook Name" + }, "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.", @@ -25,7 +73,26 @@ "institution": "Institution", "position": "Position" } + }, + "customQuestions": { + "label": "Custom Questions", + "help": "Create your own questions to have users provide more than their account information when they download a file. Questions can be required or optional and answers can be text or multiple choice.", + "typeLabel": "Question Type", + "questionText": "Question Text", + "required": "Required field", + "addQuestion": "Add question", + "types": { + "singleLine": "Single Line", + "multipleLine": "Multiple Line", + "multipleChoice": "Multiple Choice" + }, + "responseOptions": "Response Options", + "addOption": "Add response option", + "removeOption": "Remove response option", + "removeQuestion": "Remove question" } - } + }, + "submit": "Create Dataset Guestbook", + "cancel": "Cancel" } } diff --git a/public/locales/es/guestbooks.json b/public/locales/es/guestbooks.json index c2f429046..e9c7fbd32 100644 --- a/public/locales/es/guestbooks.json +++ b/public/locales/es/guestbooks.json @@ -1,7 +1,27 @@ { "title": "Libros de visitas del dataset", + "filters": { + "includeFromParent": "Incluir libros de visitas de {{parent}}" + }, + "actions": { + "create": "Crear libro de visitas del dataset", + "enable": "Habilitar", + "disable": "Deshabilitar", + "downloadAllResponses": "Descargar todas las respuestas", + "view": "Ver", + "copy": "Copiar", + "edit": "Editar", + "downloadResponses": "Descargar respuestas", + "viewResponses": "Ver respuestas" + }, "errors": { - "getGuestbook": "Algo salió mal al obtener el libro de visitas. Inténtalo de nuevo más tarde." + "getGuestbook": "Algo salió mal al obtener el libro de visitas. Inténtalo de nuevo más tarde.", + "toggleEnabled": "Algo salió mal al actualizar el estado del libro de visitas. Inténtalo de nuevo más tarde.", + "downloadResponses": "Algo salió mal al descargar las respuestas del libro de visitas. Inténtalo de nuevo más tarde." + }, + "alerts": { + "statusUpdated": "El estado del libro de visitas se ha actualizado.", + "downloadStarted": "La descarga ha comenzado." }, "preview": { "title": "Vista previa del libro de visitas", @@ -9,13 +29,41 @@ "guestbookNameLabel": "Nombre del libro de visitas", "guestbookDataLabel": "Datos recopilados", "guestbookDataTip": "Datos del usuario recopilados por el libro de visitas.", + "collectedDataLabel": "Datos recopilados", + "collectedDataTip": "Datos del usuario recopilados por el libro de visitas.", "accountInformation": "Información de la cuenta", "customQuestionsLabel": "Preguntas personalizadas", "required": "Obligatorio", "optional": "Opcional" }, + "table": { + "name": "Nombre del libro de visitas", + "created": "Creado", + "usage": "Uso", + "responses": "Respuestas", + "action": "Acción", + "guestbookCreatedAt": "Libro de visitas creado en {{alias}}" + }, + "emptyState": { + "whyTitle": "¿Por qué usar libros de visitas?", + "whyBullets": [ + "Los libros de visitas te permiten recopilar datos sobre quién descarga los ficheros de tus datasets. Puedes decidir recopilar información de la cuenta (nombre de usuario, nombre y apellido, afiliación, etc.) y crear preguntas personalizadas (por ejemplo, ¿para qué planeas usar estos datos?).", + "Puedes descargar los datos recopilados de los libros de visitas habilitados para almacenarlos fuera de Dataverse." + ], + "howTitle": "Cómo usar los libros de visitas", + "howBullets": [ + "Un libro de visitas puede utilizarse en varios datasets, pero un dataset solo puede usar un libro de visitas.", + "Las preguntas personalizadas pueden tener respuestas de texto libre o permitir que el usuario seleccione una respuesta entre varias opciones." + ], + "footer": "Para comenzar, haz clic en Editar colección en este Dataverse y luego selecciona Información general. Para obtener más información sobre los libros de visitas, visita la sección Libro de visitas del dataset de la Guía de usuario." + }, "create": { + "title": "Crear libro de visitas del dataset", + "info": "Para obtener más información sobre los libros de visitas, visita la sección Libro de visitas del dataset de la Guía de usuario.", "fields": { + "name": { + "label": "Nombre del libro de visitas" + }, "dataCollected": { "label": "Datos recopilados", "help": "Información de la cuenta de Dataverse que se recopilará cuando un usuario descargue un fichero. Marca las que serán obligatorias.", @@ -25,7 +73,26 @@ "institution": "Institución", "position": "Cargo" } + }, + "customQuestions": { + "label": "Preguntas personalizadas", + "help": "Crea tus propias preguntas para que los usuarios proporcionen más información que la de su cuenta cuando descarguen un fichero. Las preguntas pueden ser obligatorias u opcionales, y las respuestas pueden ser de texto o de opción múltiple.", + "typeLabel": "Tipo de pregunta", + "questionText": "Texto de la pregunta", + "required": "Campo obligatorio", + "addQuestion": "Añadir pregunta", + "types": { + "singleLine": "Una línea", + "multipleLine": "Varias líneas", + "multipleChoice": "Opción múltiple" + }, + "responseOptions": "Opciones de respuesta", + "addOption": "Añadir opción de respuesta", + "removeOption": "Eliminar opción de respuesta", + "removeQuestion": "Eliminar pregunta" } - } + }, + "submit": "Crear libro de visitas del dataset", + "cancel": "Cancelar" } } diff --git a/src/guestbooks/domain/models/Guestbook.ts b/src/guestbooks/domain/models/Guestbook.ts index a6d8ad46c..cc9744986 100644 --- a/src/guestbooks/domain/models/Guestbook.ts +++ b/src/guestbooks/domain/models/Guestbook.ts @@ -26,4 +26,6 @@ export interface Guestbook { customQuestions: GuestbookCustomQuestion[] createTime: string dataverseId: number + usageCount?: number + responseCount?: number } diff --git a/src/guestbooks/domain/models/GuestbookResponse.ts b/src/guestbooks/domain/models/GuestbookResponse.ts new file mode 100644 index 000000000..6d915f8fd --- /dev/null +++ b/src/guestbooks/domain/models/GuestbookResponse.ts @@ -0,0 +1,32 @@ +export interface GuestbookResponse { + id: number + dataset: string + datasetPid: string + date: string + type: EventType + fileName?: string + fileId?: number + filePid?: string + userName: string + email?: string + institution?: string + position?: string + customQuestions?: GuestbookResponseCustomQuestion[] +} + +export interface GuestbookResponseCustomQuestion { + question: string + response: string +} + +export interface GuestbookResponseSubset { + guestbookResponses: GuestbookResponse[] + totalGuestbookResponseCount: number +} + +export enum EventType { + ACCESS_REQUEST = 'AccessRequest', + DOWNLOAD = 'Download', + SUBSET = 'Subset', + EXPLORE = 'Explore' +} diff --git a/src/guestbooks/domain/repositories/GuestbookRepository.ts b/src/guestbooks/domain/repositories/GuestbookRepository.ts index a596c1a50..1da6afee4 100644 --- a/src/guestbooks/domain/repositories/GuestbookRepository.ts +++ b/src/guestbooks/domain/repositories/GuestbookRepository.ts @@ -1,8 +1,33 @@ +import { type CreateGuestbookDTO } from '@iqss/dataverse-client-javascript' import { Guestbook } from '../models/Guestbook' +import { GuestbookResponseSubset } from '../models/GuestbookResponse' export interface GuestbookRepository { + createGuestbook: ( + collectionIdOrAlias: number | string, + guestbook: CreateGuestbookDTO + ) => Promise getGuestbook: (guestbookId: number) => Promise - getGuestbooksByCollectionId: (collectionIdOrAlias: number | string) => Promise + getGuestbooksByCollectionId: ( + collectionIdOrAlias: number | string, + includeStats?: boolean, + includeInherited?: boolean + ) => Promise + getGuestbookResponsesByGuestbookId: ( + guestbookId: number, + limit?: number, + offset?: number + ) => Promise + setGuestbookEnabled: ( + collectionIdOrAlias: number | string, + guestbookId: number, + enabled: boolean + ) => Promise + downloadGuestbookResponsesByCollectionId: (collectionId: number | string) => Promise + downloadGuestbookResponsesByGuestbookId: ( + collectionId: number | string, + guestbookId: number + ) => Promise assignDatasetGuestbook: (datasetId: number | string, guestbookId: number) => Promise removeDatasetGuestbook: (datasetId: number | string) => Promise } diff --git a/src/guestbooks/domain/useCases/createGuestbook.ts b/src/guestbooks/domain/useCases/createGuestbook.ts new file mode 100644 index 000000000..905d630cc --- /dev/null +++ b/src/guestbooks/domain/useCases/createGuestbook.ts @@ -0,0 +1,10 @@ +import { type CreateGuestbookDTO } from '@iqss/dataverse-client-javascript' +import { GuestbookRepository } from '../repositories/GuestbookRepository' + +export function createGuestbook( + guestbookRepository: GuestbookRepository, + collectionIdOrAlias: number | string, + guestbook: CreateGuestbookDTO +): Promise { + return guestbookRepository.createGuestbook(collectionIdOrAlias, guestbook) +} diff --git a/src/guestbooks/domain/useCases/downloadGuestbookResponsesByCollectionId.ts b/src/guestbooks/domain/useCases/downloadGuestbookResponsesByCollectionId.ts new file mode 100644 index 000000000..efd3cd74d --- /dev/null +++ b/src/guestbooks/domain/useCases/downloadGuestbookResponsesByCollectionId.ts @@ -0,0 +1,8 @@ +import { GuestbookRepository } from '../repositories/GuestbookRepository' + +export function downloadGuestbookResponsesByCollectionId( + guestbookRepository: GuestbookRepository, + collectionId: number | string +): Promise { + return guestbookRepository.downloadGuestbookResponsesByCollectionId(collectionId) +} diff --git a/src/guestbooks/domain/useCases/downloadGuestbookResponsesByGuestbookId.ts b/src/guestbooks/domain/useCases/downloadGuestbookResponsesByGuestbookId.ts new file mode 100644 index 000000000..53dd3e3f6 --- /dev/null +++ b/src/guestbooks/domain/useCases/downloadGuestbookResponsesByGuestbookId.ts @@ -0,0 +1,9 @@ +import { GuestbookRepository } from '../repositories/GuestbookRepository' + +export function downloadGuestbookResponsesByGuestbookId( + guestbookRepository: GuestbookRepository, + collectionId: number | string, + guestbookId: number +): Promise { + return guestbookRepository.downloadGuestbookResponsesByGuestbookId(collectionId, guestbookId) +} diff --git a/src/guestbooks/domain/useCases/getGuestbookResponsesByGuestbookId.ts b/src/guestbooks/domain/useCases/getGuestbookResponsesByGuestbookId.ts new file mode 100644 index 000000000..64f6ac5c5 --- /dev/null +++ b/src/guestbooks/domain/useCases/getGuestbookResponsesByGuestbookId.ts @@ -0,0 +1,11 @@ +import { GuestbookResponseSubset } from '../models/GuestbookResponse' +import { GuestbookRepository } from '../repositories/GuestbookRepository' + +export function getGuestbookResponsesByGuestbookId( + guestbookRepository: GuestbookRepository, + guestbookId: number, + limit?: number, + offset?: number +): Promise { + return guestbookRepository.getGuestbookResponsesByGuestbookId(guestbookId, limit, offset) +} diff --git a/src/guestbooks/domain/useCases/setGuestbookEnabled.ts b/src/guestbooks/domain/useCases/setGuestbookEnabled.ts new file mode 100644 index 000000000..77dad1d2d --- /dev/null +++ b/src/guestbooks/domain/useCases/setGuestbookEnabled.ts @@ -0,0 +1,10 @@ +import { GuestbookRepository } from '../repositories/GuestbookRepository' + +export function setGuestbookEnabled( + guestbookRepository: GuestbookRepository, + collectionIdOrAlias: number | string, + guestbookId: number, + enabled: boolean +): Promise { + return guestbookRepository.setGuestbookEnabled(collectionIdOrAlias, guestbookId, enabled) +} diff --git a/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts b/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts index 7925752ef..e139107d2 100644 --- a/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts +++ b/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts @@ -1,23 +1,76 @@ import { assignDatasetGuestbook, + createGuestbook, + downloadGuestbookResponsesByCollectionId, + downloadGuestbookResponsesOfAGuestbook as downloadGuestbookResponsesByGuestbookId, + type CreateGuestbookDTO, getGuestbooksByCollectionId, getGuestbook, + getGuestbookResponsesByGuestbookId, + setGuestbookEnabled, removeDatasetGuestbook } from '@iqss/dataverse-client-javascript' import { GuestbookRepository } from '../../domain/repositories/GuestbookRepository' import { Guestbook } from '../../domain/models/Guestbook' +import { GuestbookResponseSubset } from '../../domain/models/GuestbookResponse' export class GuestbookJSDataverseRepository implements GuestbookRepository { + createGuestbook( + collectionIdOrAlias: number | string, + guestbook: CreateGuestbookDTO + ): Promise { + return createGuestbook.execute(guestbook, collectionIdOrAlias) + } + getGuestbook(guestbookId: number): Promise { return getGuestbook.execute(guestbookId).then((guestbook) => guestbook as Guestbook) } - getGuestbooksByCollectionId(collectionIdOrAlias: number | string): Promise { + getGuestbooksByCollectionId( + collectionIdOrAlias: number | string, + includeStats = false, + includeInherited = false + ): Promise { return getGuestbooksByCollectionId - .execute(collectionIdOrAlias) + .execute(collectionIdOrAlias, includeStats, includeInherited) .then((guestbooks) => guestbooks as Guestbook[]) } + getGuestbookResponsesByGuestbookId( + guestbookId: number, + limit?: number, + offset?: number + ): Promise { + return getGuestbookResponsesByGuestbookId + .execute(guestbookId, limit, offset) + .then((subset) => ({ + ...subset, + guestbookResponses: subset.guestbookResponses.map(({ name, ...response }) => ({ + ...response, + userName: name + })) + })) + } + + setGuestbookEnabled( + collectionIdOrAlias: number | string, + guestbookId: number, + enabled: boolean + ): Promise { + return setGuestbookEnabled.execute(collectionIdOrAlias, guestbookId, enabled) + } + + downloadGuestbookResponsesByCollectionId(collectionId: number | string): Promise { + return downloadGuestbookResponsesByCollectionId.execute(collectionId) + } + + downloadGuestbookResponsesByGuestbookId( + collectionId: number | string, + guestbookId: number + ): Promise { + return downloadGuestbookResponsesByGuestbookId.execute(collectionId, guestbookId) + } + assignDatasetGuestbook(datasetId: number | string, guestbookId: number): Promise { return assignDatasetGuestbook.execute(datasetId, guestbookId) } diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 976ca5e97..5d9cbd3bb 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -6,6 +6,7 @@ import { ErrorPage } from '@/sections/error-page/ErrorPage' import { AppLoader } from '@/sections/shared/layout/app-loader/AppLoader' import { AuthCallback } from '@/sections/auth-callback/AuthCallback' import { SessionProvider } from '@/sections/session/SessionProvider' +import { GuestbookSkeleton } from '@/sections/guestbooks/GuestbookSkeleton' import { ProtectedRoute } from './ProtectedRoute' const Homepage = lazy(() => @@ -132,6 +133,20 @@ const FeaturedItemPage = lazy(() => })) ) +const GuestbooksPage = lazy(() => + import('../sections/guestbooks/GuestbooksFactory').then(({ GuestbooksFactory }) => ({ + default: () => GuestbooksFactory.create() + })) +) + +const CreateGuestbookPage = lazy(() => + import('../sections/guestbooks/create-guestbooks/CreateGuestbookFactory').then( + ({ CreateGuestbookFactory }) => ({ + default: () => CreateGuestbookFactory.create() + }) + ) +) + const NotFoundPage = lazy(() => import('../sections/not-found-page/NotFoundPageFactory').then(({ NotFoundPageFactory }) => ({ default: () => NotFoundPageFactory.create() @@ -311,6 +326,15 @@ export const routes: RouteObject[] = [ ), errorElement: }, + { + path: Route.GUESTBOOKS, + element: ( + }> + + + ), + errorElement: + }, { path: Route.COLLECTION_TEMPLATES, element: ( @@ -320,6 +344,15 @@ export const routes: RouteObject[] = [ ), errorElement: }, + { + path: Route.GUESTBOOKS_CREATE, + element: ( + }> + + + ), + errorElement: + }, { path: Route.TEMPLATES_CREATE, element: ( diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index d9853c57a..642498b6c 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -21,6 +21,8 @@ export enum Route { EDIT_COLLECTION = '/collections/:collectionId/edit', EDIT_FEATURED_ITEMS = '/collections/:collectionId/edit-featured-items', COLLECTION_TEMPLATES = '/:collectionId/templates', + GUESTBOOKS = '/:collectionId/guestbooks', + GUESTBOOKS_CREATE = '/:collectionId/guestbooks/create', TEMPLATES_CREATE = '/:collectionId/templates/create', TEMPLATES_EDIT_METADATA = '/:collectionId/templates/:templateId/edit/metadata', TEMPLATES_EDIT_TERMS = '/:collectionId/templates/:templateId/edit/terms', @@ -39,6 +41,8 @@ export const RouteWithParams = { EDIT_COLLECTION: (collectionId: string) => `/collections/${collectionId}/edit`, EDIT_FEATURED_ITEMS: (collectionId: string) => `/collections/${collectionId}/edit-featured-items`, COLLECTION_TEMPLATES: (collectionId: string) => `/${collectionId}/templates`, + GUESTBOOKS: (collectionId: string) => `/${collectionId}/guestbooks`, + GUESTBOOKS_CREATE: (collectionId: string) => `/${collectionId}/guestbooks/create`, TEMPLATES_CREATE: (collectionId: string) => `/${collectionId}/templates/create`, TEMPLATES_EDIT_METADATA: (collectionId: string, templateId: number | string) => `/${collectionId}/templates/${templateId}/edit/metadata`, diff --git a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardHeader.tsx b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardHeader.tsx index d7af98a8a..bbfb0e2e1 100644 --- a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardHeader.tsx +++ b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCardHeader.tsx @@ -44,7 +44,12 @@ export function CollectionCardHeader({ collectionPreview }: CollectionCardHeader
{collectionPreview.isLinked && ( - + )}
diff --git a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader.tsx b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader.tsx index a5e948e3a..c367b6042 100644 --- a/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader.tsx +++ b/src/sections/collection/collection-items-panel/items-list/dataset-card/DatasetCardHeader.tsx @@ -66,7 +66,14 @@ export function DatasetCardHeader({
- {isLinked && } + {isLinked && ( + + )}
) diff --git a/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx b/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx index 4a81cbd73..23e107de9 100644 --- a/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx +++ b/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Link } from 'react-router-dom' +import { type NavigateFunction, Link, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { DropdownButton, @@ -27,6 +27,7 @@ export const EditCollectionDropdown = ({ canUserDeleteCollection }: EditCollectionDropdownProps) => { const { t } = useTranslation('collection') + const navigate: NavigateFunction = useNavigate() const [showNotImplementedModal, setShowNotImplementedModal] = useState(false) const canCollectionBeDeleted = @@ -38,6 +39,7 @@ export const EditCollectionDropdown = ({ event.stopPropagation() setShowNotImplementedModal(true) } + const handleDatasetGuestbooksClick = () => navigate(RouteWithParams.GUESTBOOKS(collection.id)) return ( <> @@ -78,7 +80,7 @@ export const EditCollectionDropdown = ({ {t('editCollection.datasetTemplates')} - + {t('editCollection.datasetGuestbooks')} diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithTermsAndGuestbookModal.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithTermsAndGuestbookModal.tsx index 7edb8b112..78da50b7d 100644 --- a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithTermsAndGuestbookModal.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithTermsAndGuestbookModal.tsx @@ -193,7 +193,7 @@ export function DownloadWithTermsAndGuestbookModal({ answers.push({ id: resolveAnswerId(fieldName, question, guestbook), - value + value: question.type === 'textarea' ? value.split(/\r?\n/) : value }) return answers diff --git a/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx index 8f87175c5..eb8835dce 100644 --- a/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx +++ b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx @@ -1,4 +1,4 @@ -import { startTransition, useCallback, useEffect, useState } from 'react' +import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { Alert, Button, Col, Form, Row, Spinner } from '@iqss/dataverse-design-system' import { Trans, useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' @@ -32,6 +32,8 @@ export function EditGuestbook({ onPreview, onFormStateChange }: EditGuestbookPro const navigate = useNavigate() const collectionIdOrAlias = dataset?.parentCollectionNode?.id const collectionName = dataset?.parentCollectionNode?.name ?? '' + const includeInheritedGuestbooks = Boolean(dataset?.parentCollectionNode?.parent) + //TODO: we should add includeInheritedGuestbooks boolean to dataset api response const navigateToDatasetView = useCallback(() => { if (!dataset) return @@ -55,8 +57,13 @@ export function EditGuestbook({ onPreview, onFormStateChange }: EditGuestbookPro const { guestbooks, isLoadingGuestbooksByCollectionId, errorGetGuestbooksByCollectionId } = useGetGuestbooksByCollectionId({ guestbookRepository, - collectionIdOrAlias + collectionIdOrAlias, + includeInherited: includeInheritedGuestbooks }) + const enabledGuestbooks = useMemo( + () => guestbooks.filter((guestbook) => guestbook.enabled), + [guestbooks] + ) const { handleAssignDatasetGuestbook, isLoadingAssignDatasetGuestbook, @@ -83,7 +90,7 @@ export function EditGuestbook({ onPreview, onFormStateChange }: EditGuestbookPro }) useEffect(() => { - if (guestbooks.length === 0) { + if (enabledGuestbooks.length === 0) { setSelectedGuestbookId(undefined) return } @@ -93,14 +100,14 @@ export function EditGuestbook({ onPreview, onFormStateChange }: EditGuestbookPro if (currentDatasetGuestbookId === undefined) { setSelectedGuestbookId((currentSelectedGuestbookId) => currentSelectedGuestbookId !== undefined && - guestbooks.some((guestbook) => guestbook.id === currentSelectedGuestbookId) + enabledGuestbooks.some((guestbook) => guestbook.id === currentSelectedGuestbookId) ? currentSelectedGuestbookId : undefined ) return } - const hasCurrentDatasetGuestbook = guestbooks.some( + const hasCurrentDatasetGuestbook = enabledGuestbooks.some( (guestbook) => guestbook.id === currentDatasetGuestbookId ) @@ -112,14 +119,14 @@ export function EditGuestbook({ onPreview, onFormStateChange }: EditGuestbookPro setSelectedGuestbookId((currentSelectedGuestbookId) => { if ( currentSelectedGuestbookId !== undefined && - guestbooks.some((guestbook) => guestbook.id === currentSelectedGuestbookId) + enabledGuestbooks.some((guestbook) => guestbook.id === currentSelectedGuestbookId) ) { return currentSelectedGuestbookId } return currentDatasetGuestbookId }) - }, [dataset?.guestbookId, guestbooks]) + }, [dataset?.guestbookId, enabledGuestbooks]) useEffect(() => { onFormStateChange?.(selectedGuestbookId !== dataset?.guestbookId) @@ -167,7 +174,7 @@ export function EditGuestbook({ onPreview, onFormStateChange }: EditGuestbookPro /> {!isLoadingGuestbooksByCollectionId && !errorGetGuestbooksByCollectionId && - guestbooks.length === 0 && ( + enabledGuestbooks.length === 0 && (
{t('editTerms.guestbook.noGuestbooksEnabled', { collectionName })}
@@ -176,7 +183,7 @@ export function EditGuestbook({ onPreview, onFormStateChange }: EditGuestbookPro {!isLoadingGuestbooksByCollectionId && !errorGetGuestbooksByCollectionId && - guestbooks.length > 0 && + enabledGuestbooks.length > 0 && selectedGuestbookId !== undefined && (
+ )} +
+ + {guestbookFetchErrors.map((error) => ( + + {error} + + ))} + {toggleGuestbookError && {toggleGuestbookError}} + {downloadResponsesError && {downloadResponsesError}} + + {filteredGuestbooks.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {sortedGuestbooks.map((guestbook) => ( + + + + + + + + ))} + +
+ + + + + + + + + {t('table.action')} +
{guestbook.name}{new Date(guestbook.createTime).toLocaleDateString()}{guestbook.usageCount ?? 0}{guestbook.responseCount ?? 0} + {!isGuestbookFromCurrentCollection(guestbook) && ( + + {t('table.guestbookCreatedAt', { + alias: getGuestbookOriginAlias(guestbook) + })} + + )} + setGuestbookToPreview(guestbook)} + onToggleEnabled={() => handleToggleEnabled(guestbook)} + canToggleEnabled={isGuestbookFromCurrentCollection(guestbook)} + canEdit={isGuestbookFromCurrentCollection(guestbook)} + isTogglingEnabled={togglingGuestbookId === guestbook.id} + onDownloadResponses={() => handleDownloadResponses(guestbook)} + isDownloadingResponses={downloadingGuestbookId === guestbook.id} + actionGroupClassName={styles['action-group']} + toggleStatusButtonClassName={styles['toggle-status-button']} + /> +
+ )} + + ) +} diff --git a/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx b/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx new file mode 100644 index 000000000..31f39486b --- /dev/null +++ b/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, ButtonGroup, Tooltip } from '@iqss/dataverse-design-system' +import { Download, Eye, Files, Pencil } from 'react-bootstrap-icons' +import { NotImplementedModal } from '@/sections/not-implemented/NotImplementedModal' + +interface GuestbookActionButtonsProps { + isEnabled: boolean + onView: () => void + onToggleEnabled: () => void + canToggleEnabled?: boolean + canEdit?: boolean + isTogglingEnabled?: boolean + onDownloadResponses: () => void + isDownloadingResponses?: boolean + actionGroupClassName?: string + toggleStatusButtonClassName?: string +} + +export const GuestbookActionButtons = ({ + isEnabled, + onView, + onToggleEnabled, + canToggleEnabled = true, + canEdit = true, + isTogglingEnabled = false, + onDownloadResponses, + isDownloadingResponses = false, + actionGroupClassName, + toggleStatusButtonClassName +}: GuestbookActionButtonsProps) => { + const { t } = useTranslation('guestbooks') + const [showNotImplementedModal, setShowNotImplementedModal] = useState(false) + + return ( + <> + + {canToggleEnabled && ( + + )} + + + + + + + {canEdit && ( + + + + )} + + + + + + setShowNotImplementedModal(false)} + /> + + ) +} diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbook.module.scss b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.module.scss new file mode 100644 index 000000000..645f4e780 --- /dev/null +++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.module.scss @@ -0,0 +1,63 @@ +@import 'node_modules/bootstrap/scss/functions'; +@import 'node_modules/bootstrap/scss/variables'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.form { + margin-top: 1.5rem; +} + +.form-row { + margin-bottom: 2rem; +} + +.row-label { + font-weight: 700; +} + +.help { + color: $dv-subtext-color; + margin-bottom: 1rem; +} + +.checkboxes { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.add-question-button { + min-width: 3rem; +} + +.question-controls { + display: flex; + gap: 0.75rem; +} + +.question-block { + margin-bottom: 1rem; +} + +.response-options { + margin-top: 0.75rem; +} + +.option-row { + margin-top: 0.75rem; +} + +.option-button { + min-width: 3rem; +} + +.question-separator { + border-bottom: 1px solid rgba($dv-subtext-color, 0.2); + margin-top: 1rem; +} + +.actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; +} diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx new file mode 100644 index 000000000..3a2a2389e --- /dev/null +++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx @@ -0,0 +1,409 @@ +import { useEffect, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { type CreateGuestbookDTO } from '@iqss/dataverse-client-javascript' +import { Alert, Button, Col, Form, Row } from '@iqss/dataverse-design-system' +import { type NavigateFunction, useNavigate } from 'react-router-dom' +import { DashLg, PlusLg } from 'react-bootstrap-icons' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { GuestbookQuestionType } from '@/guestbooks/domain/models/Guestbook' +import { RouteWithParams } from '@/sections/Route.enum' +import { useCollection } from '@/sections/collection/useCollection' +import { NotFoundPage } from '@/sections/not-found-page/NotFoundPage' +import { BreadcrumbsGenerator } from '@/sections/shared/hierarchy/BreadcrumbsGenerator' +import { useLoading } from '@/shared/contexts/loading/LoadingContext' +import { useGuestbookRepositories } from '@/shared/contexts/repositories/RepositoriesProvider' +import { GuestbookSkeleton } from '../GuestbookSkeleton' +import { useCreateGuestbook } from './useCreateGuestbook' +import styles from './CreateGuestbook.module.scss' + +interface CreateGuestbookProps { + collectionId: string + collectionRepository: CollectionRepository +} + +interface CustomQuestionDraft { + id: number + type: GuestbookQuestionType + questionText: string + required: boolean + responseOptions: string[] +} + +export const CreateGuestbook = ({ collectionId, collectionRepository }: CreateGuestbookProps) => { + const { t } = useTranslation('guestbooks') + const navigate: NavigateFunction = useNavigate() + const { guestbookRepository } = useGuestbookRepositories() + const { setIsLoading } = useLoading() + const { collection, isLoading } = useCollection(collectionRepository, collectionId) + const [guestbookName, setGuestbookName] = useState('') + const [nameRequired, setNameRequired] = useState(false) + const [emailRequired, setEmailRequired] = useState(false) + const [institutionRequired, setInstitutionRequired] = useState(false) + const [positionRequired, setPositionRequired] = useState(false) + const [customQuestions, setCustomQuestions] = useState([ + { + id: 1, + type: 'text', + questionText: '', + required: false, + responseOptions: [''] + } + ]) + const guestbooksGuideUrl = + 'https://guides.dataverse.org/en/latest/user/dataverse-management.html#dataset-guestbooks' + const guestbooksRoute = RouteWithParams.GUESTBOOKS(collectionId) + const navigateToGuestbooks = () => navigate(guestbooksRoute) + const { isCreatingGuestbook, errorCreatingGuestbook, handleCreateGuestbook } = useCreateGuestbook( + { + guestbookRepository, + collectionIdOrAlias: collectionId, + onSuccessfulCreate: navigateToGuestbooks + } + ) + + useEffect(() => { + setIsLoading(isLoading) + }, [isLoading, setIsLoading]) + + const updateQuestion = ( + questionId: number, + updater: (question: CustomQuestionDraft) => CustomQuestionDraft + ) => { + setCustomQuestions((current) => + current.map((question) => (question.id === questionId ? updater(question) : question)) + ) + } + + const addQuestionAfter = (questionId: number) => { + setCustomQuestions((current) => { + const nextId = Math.max(...current.map((question) => question.id), 0) + 1 + const newQuestion: CustomQuestionDraft = { + id: nextId, + type: 'text', + questionText: '', + required: false, + responseOptions: [''] + } + + const insertionIndex = current.findIndex((question) => question.id === questionId) + if (insertionIndex === -1) { + return [...current, newQuestion] + } + + const nextQuestions = [...current] + nextQuestions.splice(insertionIndex + 1, 0, newQuestion) + return nextQuestions + }) + } + + const removeQuestion = (questionId: number) => { + setCustomQuestions((current) => { + if (current.length === 1) { + return current + } + return current.filter((question) => question.id !== questionId) + }) + } + + const addOptionLine = (questionId: number, optionIndex: number) => { + updateQuestion(questionId, (question) => { + const nextOptions = [...question.responseOptions] + nextOptions.splice(optionIndex + 1, 0, '') + return { ...question, responseOptions: nextOptions } + }) + } + + const removeOptionLine = (questionId: number, optionIndex: number) => { + updateQuestion(questionId, (question) => { + if (question.responseOptions.length === 1) { + return question + } + const nextOptions = question.responseOptions.filter((_, index) => index !== optionIndex) + return { ...question, responseOptions: nextOptions } + }) + } + + const buildGuestbookDTO = (): CreateGuestbookDTO => ({ + name: guestbookName.trim(), + enabled: true, + nameRequired, + emailRequired, + institutionRequired, + positionRequired, + customQuestions: customQuestions + .filter((question) => question.questionText.trim().length > 0) + .map((question, index) => ({ + question: question.questionText.trim(), + required: question.required, + displayOrder: index, + type: question.type, + hidden: false, + optionValues: + question.type === 'options' + ? question.responseOptions + .filter((option) => option.trim().length > 0) + .map((option, optionIndex) => ({ + value: option.trim(), + displayOrder: optionIndex + })) + : undefined + })) + }) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + void handleCreateGuestbook(buildGuestbookDTO()) + } + + if (!isLoading && !collection) { + return + } + + if (isLoading || !collection) { + return + } + + return ( +
+ + + + + }} + /> + + + {errorCreatingGuestbook && {errorCreatingGuestbook}} + +
+ + + {t('create.fields.name.label')} + + + setGuestbookName(event.target.value)} + /> + + + + + + {t('create.fields.dataCollected.label')} + + +

{t('create.fields.dataCollected.help')}

+
+ setNameRequired((current) => !current)} + /> + setEmailRequired((current) => !current)} + /> + setInstitutionRequired((current) => !current)} + /> + setPositionRequired((current) => !current)} + /> +
+ +
+ + + + {t('create.fields.customQuestions.label')} + + +

{t('create.fields.customQuestions.help')}

+ {customQuestions.map((question, questionIndex) => ( +
+ + + + {t('create.fields.customQuestions.typeLabel')} + + + updateQuestion(question.id, (current) => ({ + ...current, + type: event.target.value as GuestbookQuestionType + })) + }> + + + + + + + + {t('create.fields.customQuestions.questionText')} + + + updateQuestion(question.id, (current) => ({ + ...current, + questionText: event.target.value + })) + } + /> + + +
+ + +
+ +
+ + {question.type === 'options' && ( +
+ + +
+ + + + {t('create.fields.customQuestions.responseOptions')} + + + + {question.responseOptions.map((responseOption, optionIndex) => ( + + +
+ + + + updateQuestion(question.id, (current) => ({ + ...current, + responseOptions: current.responseOptions.map((option, index) => + index === optionIndex ? event.target.value : option + ) + })) + } + /> + + +
+ + +
+ + + ))} +
+ )} + + + updateQuestion(question.id, (current) => ({ + ...current, + required: !current.required + })) + } + /> + + {questionIndex !== customQuestions.length - 1 && ( +
+ )} +
+ ))} + + + +
+ + +
+ +
+ ) +} diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbookButton.tsx b/src/sections/guestbooks/create-guestbooks/CreateGuestbookButton.tsx new file mode 100644 index 000000000..ce99ff7cf --- /dev/null +++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbookButton.tsx @@ -0,0 +1,25 @@ +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { Button } from '@iqss/dataverse-design-system' +import { PlusLg } from 'react-bootstrap-icons' +import { RouteWithParams } from '@/sections/Route.enum' + +interface CreateGuestbookButtonProps { + collectionId: string + className?: string +} + +export const CreateGuestbookButton = ({ collectionId, className }: CreateGuestbookButtonProps) => { + const { t } = useTranslation('guestbooks') + const navigate = useNavigate() + + return ( + + ) +} diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbookFactory.tsx b/src/sections/guestbooks/create-guestbooks/CreateGuestbookFactory.tsx new file mode 100644 index 000000000..39120d69b --- /dev/null +++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbookFactory.tsx @@ -0,0 +1,18 @@ +import { ReactElement } from 'react' +import { useParams } from 'react-router-dom' +import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository' +import { CreateGuestbook } from './CreateGuestbook' + +const collectionRepository = new CollectionJSDataverseRepository() + +export class CreateGuestbookFactory { + static create(): ReactElement { + return + } +} + +function CreateGuestbookWithParams() { + const { collectionId } = useParams<{ collectionId: string }>() as { collectionId: string } + + return +} diff --git a/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts b/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts new file mode 100644 index 000000000..03f348d3b --- /dev/null +++ b/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { type CreateGuestbookDTO, WriteError } from '@iqss/dataverse-client-javascript' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { createGuestbook } from '@/guestbooks/domain/useCases/createGuestbook' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' + +interface UseCreateGuestbookProps { + guestbookRepository: GuestbookRepository + collectionIdOrAlias: number | string + onSuccessfulCreate?: (guestbookId: number) => void +} + +export const useCreateGuestbook = ({ + guestbookRepository, + collectionIdOrAlias, + onSuccessfulCreate +}: UseCreateGuestbookProps) => { + const [isCreatingGuestbook, setIsCreatingGuestbook] = useState(false) + const [errorCreatingGuestbook, setErrorCreatingGuestbook] = useState(null) + + const handleCreateGuestbook = async (guestbook: CreateGuestbookDTO) => { + setIsCreatingGuestbook(true) + setErrorCreatingGuestbook(null) + + try { + const guestbookId = await createGuestbook(guestbookRepository, collectionIdOrAlias, guestbook) + onSuccessfulCreate?.(guestbookId) + return guestbookId + } catch (err: WriteError | unknown) { + if (err instanceof WriteError) { + const error = new JSDataverseWriteErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + setErrorCreatingGuestbook(formattedError) + } else { + setErrorCreatingGuestbook('Something went wrong creating the guestbook. Try again later.') + } + } finally { + setIsCreatingGuestbook(false) + } + } + + return { + isCreatingGuestbook, + errorCreatingGuestbook, + handleCreateGuestbook + } +} diff --git a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx index 6286547ff..c6659c2d4 100644 --- a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx +++ b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx @@ -8,12 +8,16 @@ interface UseGetGuestbooksByCollectionIdProps { guestbookRepository: GuestbookRepository collectionIdOrAlias?: number | string autoFetch?: boolean + includeStats?: boolean + includeInherited?: boolean } export const useGetGuestbooksByCollectionId = ({ guestbookRepository, collectionIdOrAlias, - autoFetch = true + autoFetch = true, + includeStats = false, + includeInherited = false }: UseGetGuestbooksByCollectionIdProps) => { const [guestbooks, setGuestbooks] = useState([]) const [isLoadingGuestbooksByCollectionId, setIsLoadingGuestbooksByCollectionId] = @@ -35,7 +39,9 @@ export const useGetGuestbooksByCollectionId = ({ try { const fetchedGuestbooks = await guestbookRepository.getGuestbooksByCollectionId( - collectionIdOrAlias + collectionIdOrAlias, + includeStats, + includeInherited ) setGuestbooks(Array.isArray(fetchedGuestbooks) ? fetchedGuestbooks : []) } catch (err) { @@ -53,7 +59,7 @@ export const useGetGuestbooksByCollectionId = ({ } finally { setIsLoadingGuestbooksByCollectionId(false) } - }, [collectionIdOrAlias, guestbookRepository]) + }, [collectionIdOrAlias, guestbookRepository, includeInherited, includeStats]) useEffect(() => { if (autoFetch) { diff --git a/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx b/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx index 47e251c36..e8d60ae1e 100644 --- a/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx +++ b/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx @@ -1,4 +1,5 @@ import { Breadcrumb } from '@iqss/dataverse-design-system' +import { Link } from 'react-router-dom' import { DvObjectType, UpwardHierarchyNode @@ -7,24 +8,33 @@ import { LinkToPage } from '../link-to-page/LinkToPage' import { Route } from '../../Route.enum' import styles from './BreadcrumbsGenerator.module.scss' +interface ActionItem { + text: string + url?: string +} + type BreadcrumbGeneratorProps = | { hierarchy: UpwardHierarchyNode withActionItem?: false actionItemText?: never + actionItems?: never } | { hierarchy: UpwardHierarchyNode withActionItem: true actionItemText: string + actionItems?: ActionItem[] } export function BreadcrumbsGenerator({ hierarchy, withActionItem, - actionItemText + actionItemText, + actionItems }: BreadcrumbGeneratorProps) { const hierarchyArray = hierarchy.toArray() + const resolvedActionItems = withActionItem ? actionItems ?? [{ text: actionItemText }] : [] return ( @@ -69,7 +79,29 @@ export function BreadcrumbsGenerator({ ) })} - {withActionItem && {actionItemText}} + {withActionItem && + resolvedActionItems.map((item, index) => { + const isLast = index === resolvedActionItems.length - 1 + + if (isLast || !item.url) { + return ( + + {item.text} + + ) + } + + return ( + + {item.text} + + ) + })} ) } diff --git a/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts b/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts index 94dc82127..c0015fe9b 100644 --- a/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts +++ b/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts @@ -1,5 +1,7 @@ +import { type CreateGuestbookDTO } from '@iqss/dataverse-client-javascript' import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookResponseSubset } from '@/guestbooks/domain/models/GuestbookResponse' export const storybookGuestbook: Guestbook = { id: 3, @@ -19,18 +21,58 @@ export const storybookGuestbook: Guestbook = { } ], createTime: '2026-01-01T00:00:00.000Z', - dataverseId: 1 + dataverseId: 1, + usageCount: 7, + responseCount: 3 } export class GuestbookMockRepository implements GuestbookRepository { + createGuestbook(_collectionIdOrAlias: number | string, _guestbook: CreateGuestbookDTO) { + return Promise.resolve(storybookGuestbook.id) + } + getGuestbook(_guestbookId: number): Promise { return Promise.resolve(storybookGuestbook) } - getGuestbooksByCollectionId(_collectionIdOrAlias: number | string): Promise { + getGuestbooksByCollectionId( + _collectionIdOrAlias: number | string, + _includeStats?: boolean, + _includeInherited?: boolean + ): Promise { return Promise.resolve([storybookGuestbook]) } + setGuestbookEnabled( + _collectionIdOrAlias: number | string, + _guestbookId: number, + _enabled: boolean + ): Promise { + return Promise.resolve() + } + + getGuestbookResponsesByGuestbookId( + _guestbookId: number, + _limit?: number, + _offset?: number + ): Promise { + return Promise.resolve({ + guestbookResponses: [], + totalGuestbookResponseCount: 0 + }) + } + + downloadGuestbookResponsesByCollectionId(_collectionIdOrAlias: number | string): Promise { + return Promise.resolve('name,email\nJane Doe,jane@example.com') + } + + downloadGuestbookResponsesByGuestbookId( + _dataverseId: number | string, + _guestbookId: number + ): Promise { + return Promise.resolve('name,email\nJane Doe,jane@example.com') + } + assignDatasetGuestbook(_datasetId: number | string, _guestbookId: number): Promise { return Promise.resolve() } diff --git a/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx b/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx index a5f57b9f1..fda626530 100644 --- a/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx +++ b/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx @@ -1,10 +1,17 @@ import { ComponentProps } from 'react' +import { useLocation } from 'react-router-dom' import { CollectionItemsPanel as BaseCollectionItemsPanel } from '@/sections/collection/collection-items-panel/CollectionItemsPanel' import { CollectionItemSubset } from '@/collection/domain/models/CollectionItemSubset' +import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { CollectionItemsMother } from '@tests/component/collection/domain/models/CollectionItemsMother' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' import { WithRepositories } from '@tests/component/WithRepositories' +import { + CollectionSearchCriteria, + OrderType, + SortType +} from '@/collection/domain/models/CollectionSearchCriteria' const ROOT_COLLECTION_ALIAS = 'root' const collectionRepository: CollectionRepository = {} as CollectionRepository @@ -39,11 +46,30 @@ function CollectionItemsPanel({ ) } +function LocationSearchProbe() { + const location = useLocation() + + return {location.search} +} + +type GetItemsStub = sinon.SinonStub< + [ + collectionId: string, + paginationInfo: CollectionItemsPaginationInfo, + searchCriteria?: CollectionSearchCriteria, + searchService?: string + ], + Promise +> + describe('CollectionItemsPanel', () => { + let getItems: GetItemsStub + beforeEach(() => { cy.viewport(1280, 720) - collectionRepository.getItems = cy.stub().resolves(itemsWithCount) + getItems = cy.stub().resolves(itemsWithCount) as GetItemsStub + collectionRepository.getItems = getItems }) it('renders skeleton while loading', () => { @@ -414,6 +440,53 @@ describe('CollectionItemsPanel', () => { cy.findByRole('button', { name: /Search submit/ }).click() }) + it('clears existing sort, order and facet query params when submitting a new search', () => { + cy.customMount( + } + />, + ['/?sort=date&order=asc&fqs=dvCategory%3ADepartment'] + ) + + cy.findByPlaceholderText('Search this collection...').type('Some search') + cy.findByRole('button', { name: /Search submit/ }).click() + + cy.findByTestId('location-search').should(($locationSearch) => { + const params = new URLSearchParams($locationSearch.text().replace(/^\?/, '')) + + expect(params.get('q')).to.equal('Some search') + expect(params.get('types')).to.equal('collection,dataset,file') + expect(params.get('sort')).to.equal(null) + expect(params.get('order')).to.equal(null) + expect(params.get('fqs')).to.equal(null) + }) + + cy.wrap(null).should(() => { + expect(getItems).to.have.been.calledTwice + + const searchCriteria = getItems.getCall(1).args[2] as CollectionSearchCriteria + expect(searchCriteria.searchText).to.equal('Some search') + expect(searchCriteria.itemTypes).to.deep.equal([ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET, + CollectionItemType.FILE + ]) + expect(searchCriteria.sort).to.equal(SortType.SCORE) + expect(searchCriteria.order).to.equal(OrderType.DESC) + expect(searchCriteria.filterQueries).to.equal(undefined) + }) + }) + it('changes the types correctly without an existing search value', () => { cy.customMount( { .click() }) + it('adds a facet filter from the filter panel when there are no current filter queries', () => { + cy.customMount( + } + /> + ) + + cy.findByRole('button', { name: 'Add Department facet filter' }).click() + + cy.findByTestId('location-search').should(($locationSearch) => { + const params = new URLSearchParams($locationSearch.text().replace(/^\?/, '')) + + expect(params.get('fqs')).to.equal('dvCategory:Department') + }) + + cy.wrap(null).should(() => { + expect(getItems).to.have.been.calledTwice + + const searchCriteria = getItems.getCall(1).args[2] as CollectionSearchCriteria + expect(searchCriteria.filterQueries).to.deep.equal(['dvCategory:Department']) + }) + }) + + it('removes a selected facet filter through SelectedFacets', () => { + cy.customMount( + } + />, + ['/?fqs=dvCategory%3ADepartment,authorName_ss%3AAdmin%252C%2520Dataverse'] + ) + + cy.findAllByRole('button', { name: 'Remove Department facet filter' }).first().click() + + cy.findByTestId('location-search').should(($locationSearch) => { + const params = new URLSearchParams($locationSearch.text().replace(/^\?/, '')) + + expect(params.get('fqs')).to.equal('authorName_ss:Admin%2C%20Dataverse') + }) + + cy.wrap(null).should(() => { + expect(getItems).to.have.been.calledTwice + + const searchCriteria = getItems.getCall(1).args[2] as CollectionSearchCriteria + expect(searchCriteria.filterQueries).to.deep.equal(['authorName_ss:Admin, Dataverse']) + }) + }) + it('it calls the loadItemsOnBackAndForwardNavigation on pop state event when navigating back and forward', () => { cy.customMount( { await act(async () => { await result.current.handleSubmit({ hasFormErrors: true, - guestbook, + guestbookResponse }) }) @@ -97,7 +98,6 @@ describe('useGuestbookCollectSubmission', () => { await act(async () => { await result.current.handleSubmit({ hasFormErrors: false, - guestbook, guestbookResponse }) }) @@ -128,7 +128,6 @@ describe('useGuestbookCollectSubmission', () => { await act(async () => { await result.current.handleSubmit({ hasFormErrors: false, - guestbook, guestbookResponse }) }) @@ -160,7 +159,6 @@ describe('useGuestbookCollectSubmission', () => { await act(async () => { await result.current.handleSubmit({ hasFormErrors: false, - guestbook, guestbookResponse }) }) @@ -187,7 +185,6 @@ describe('useGuestbookCollectSubmission', () => { await act(async () => { await result.current.handleSubmit({ hasFormErrors: false, - guestbook, guestbookResponse }) }) @@ -213,7 +210,6 @@ describe('useGuestbookCollectSubmission', () => { await act(async () => { await result.current.handleSubmit({ hasFormErrors: false, - guestbook, guestbookResponse }) }) @@ -227,6 +223,36 @@ describe('useGuestbookCollectSubmission', () => { expect(result.current.errorSubmitGuestbook).to.deep.equal(null) }) + it('stores fallback download error when downloadFromSignedUrl rejects with a non-error value', async () => { + const handleClose = cy.stub().as('handleClose') + const downloadFromSignedUrl = cy.stub().rejects('download failed') + const { result } = renderHook(() => + useGuestbookCollectSubmission({ + fileId: 10, + handleClose, + accessRepository, + downloadFromSignedUrl + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasFormErrors: false, + guestbookResponse + }) + }) + + await waitFor(() => { + expect(result.current.errorDownloadSignedUrlFile).to.deep.equal( + 'Something went wrong downloading the file. Try again later.' + ) + }) + + expect(handleClose).to.not.have.been.called + expect(downloadFromSignedUrl).to.have.been.calledOnce + expect(result.current.errorSubmitGuestbook).to.deep.equal(null) + }) + it('resets submission state on handleModalClose', async () => { const handleClose = cy.stub().as('handleClose') const downloadFromSignedUrl = cy.stub().rejects(new Error('Download failed')) @@ -242,7 +268,7 @@ describe('useGuestbookCollectSubmission', () => { await act(async () => { await result.current.handleSubmit({ hasFormErrors: false, - guestbook, + guestbookResponse }) }) @@ -278,7 +304,7 @@ describe('useGuestbookCollectSubmission', () => { await act(async () => { await result.current.handleSubmit({ hasFormErrors: false, - guestbook, + guestbookResponse }) }) @@ -293,4 +319,37 @@ describe('useGuestbookCollectSubmission', () => { expect(downloadFromSignedUrl).to.have.been.calledOnceWith('signed-url-dataset') expect(result.current.errorSubmitGuestbook).to.deep.equal(null) }) + + it('passes archival format when submitting a dataset guestbook download', async () => { + const handleClose = cy.stub().as('handleClose') + const downloadFromSignedUrl = cy.stub().resolves(undefined) + const { result } = renderHook(() => + useGuestbookCollectSubmission({ + datasetId: 999, + format: FileDownloadMode.ARCHIVAL, + handleClose, + accessRepository, + downloadFromSignedUrl + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasFormErrors: false, + + guestbookResponse + }) + }) + + expect(accessRepository.submitGuestbookForDatasetDownload).to.have.been.calledWith( + 999, + guestbookResponse, + FileDownloadMode.ARCHIVAL + ) + expect(accessRepository.submitGuestbookForDatafileDownload).to.not.have.been.called + expect(accessRepository.submitGuestbookForDatafilesDownload).to.not.have.been.called + expect(handleClose).to.have.been.calledOnce + expect(downloadFromSignedUrl).to.have.been.calledOnceWith('signed-url-dataset') + expect(result.current.errorSubmitGuestbook).to.deep.equal(null) + }) }) diff --git a/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx b/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx index 42c8e564c..60d5273b3 100644 --- a/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx +++ b/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx @@ -9,7 +9,6 @@ import { GuestbookResponseDTO } from '@/access/domain/repositories/AccessRepository' import { AccessRepositoryProvider } from '@/sections/access/AccessRepositoryProvider' -import { SessionContext } from '@/sections/session/SessionContext' import { DatasetMother } from '@tests/component/dataset/domain/models/DatasetMother' import { WithRepositories } from '@tests/component/WithRepositories' @@ -121,19 +120,6 @@ describe('DownloadWithTermsAndGuestbookModal', () => { ) - const withAnonymousSession = (component: React.ReactNode) => ( - {}, - refetchUserSession: () => Promise.resolve() - }}> - {withRepositories(component)} - - ) - beforeEach(() => { getGuestbookImpl = () => Promise.resolve(guestbook) submitGuestbookForDatafileDownloadImpl = () => @@ -142,6 +128,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => { Promise.resolve('/api/v1/access/datafiles/10,11?token=test') guestbookRepository = { + createGuestbook: cy.stub(), getGuestbook: cy .stub() .as('getGuestbook') @@ -149,6 +136,10 @@ describe('DownloadWithTermsAndGuestbookModal', () => { return getGuestbookImpl(guestbookId) }), getGuestbooksByCollectionId: cy.stub().resolves([]), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: (_datasetId: number | string, _guestbookId: number) => Promise.resolve(), removeDatasetGuestbook: (_datasetId: number | string) => Promise.resolve() @@ -175,7 +166,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => { it('renders modal title and actions', () => { cy.customMount( - withAnonymousSession( + withRepositories( { it('renders dataset terms and license when they are provided', () => { cy.customMount( - withAnonymousSession( + withRepositories( { it('renders custom dataset terms when custom terms are available', () => { cy.customMount( - withAnonymousSession( + withRepositories( { it('enables accept when no guestbook but custom terms exist (custom terms only)', () => { cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { const handleClose = cy.stub().as('handleClose') cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { ) ) - cy.findByLabelText(/^Name/).clear().type('Test User') + cy.findByLabelText(/^Name/).should('not.be.disabled').clear().type('Test User') cy.findByLabelText(/^Email/) + .should('not.be.disabled') .clear() .type('test.user@example.com') cy.findByRole('button', { name: 'Accept' }).click() @@ -346,7 +338,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => { }) cy.customMount( - withAnonymousSession( + withRepositories( { ) ) - cy.findByLabelText(/^Name/).clear().type('Test User') + cy.findByLabelText(/^Name/).should('not.be.disabled').clear().type('Test User') cy.findByLabelText(/^Email/) + .should('not.be.disabled') .clear() .type('test.user@example.com') cy.findByRole('button', { name: 'Accept' }).click() @@ -379,7 +372,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => { }) cy.customMount( - withAnonymousSession( + withRepositories( { ) ) - cy.findByLabelText(/^Name/).clear().type('Test User') + cy.findByLabelText(/^Name/).should('not.be.disabled').clear().type('Test User') cy.findByLabelText(/^Email/) + .should('not.be.disabled') .clear() .type('test.user@example.com') cy.findByRole('button', { name: 'Accept' }).click() @@ -409,7 +403,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => { }) cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { it('shows required field validation after clicking accept', () => { cy.customMount( - withAnonymousSession( + withRepositories( { it('does not show required field validation before clicking accept', () => { cy.customMount( - withAnonymousSession( + withRepositories( { getGuestbookImpl = () => Promise.resolve(guestbookWithCustomQuestions) cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { .parents('div') .first() .find('textarea') - .type('For a replication package') + .type('For a replication package{enter}With a second line') cy.findByText('Preferred format').parents('div').first().find('button').click() cy.findByText('JSON').should('exist') @@ -656,7 +650,10 @@ describe('DownloadWithTermsAndGuestbookModal', () => { position: undefined, answers: [ { id: 'custom-question-2-0', value: 'CSV' }, - { id: 'custom-question-1-1', value: 'For a replication package' } + { + id: 'custom-question-1-1', + value: ['For a replication package', 'With a second line'] + } ] } }) @@ -673,7 +670,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => { }) cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { getGuestbookImpl = () => Promise.resolve(guestbookWithCustomQuestions) cy.customMount( - withAnonymousSession( + withRepositories( { getGuestbookImpl = () => Promise.reject(new Error('some guestbook error')) cy.customMount( - withAnonymousSession( + withRepositories( { submitGuestbookForDatafileDownloadImpl = () => Promise.reject(new Error('submit failed')) cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { }) cy.customMount( - withAnonymousSession( + withRepositories( { beforeEach(() => { guestbookRepository = { + createGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub().resolves(undefined), removeDatasetGuestbook: cy.stub().resolves(undefined) } diff --git a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx index 3fee56978..761707232 100644 --- a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx @@ -109,8 +109,13 @@ describe('EditDatasetTerms', () => { cy.viewport(1920, 1080) licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses) guestbookRepository = { + createGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub().resolves(mockGuestbooks), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub().resolves(undefined), removeDatasetGuestbook: cy.stub().resolves(undefined) } diff --git a/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx index ff3814cf3..f775a33d2 100644 --- a/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx @@ -57,6 +57,19 @@ const mockGuestbooks: Guestbook[] = [ } ] +const disabledGuestbook: Guestbook = { + id: 3, + name: 'Disabled Guestbook', + enabled: false, + emailRequired: true, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 +} + describe('EditGuestbook', () => { const withProviders = (component: ReactNode, dataset: Dataset) => { datasetRepository.getByPersistentId = cy.stub().resolves(dataset) @@ -88,8 +101,13 @@ describe('EditGuestbook', () => { beforeEach(() => { guestbookRepository = { + createGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub().resolves(mockGuestbooks), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub().resolves(undefined), removeDatasetGuestbook: cy.stub().resolves(undefined) } @@ -102,10 +120,34 @@ describe('EditGuestbook', () => { cy.findByLabelText('Data Request Guestbook').should('be.checked') cy.findByLabelText('Secondary Guestbook').should('not.be.checked') + cy.wrap( + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + ).should('have.been.calledWith', 'root', false, false) cy.findByRole('button', { name: 'Clear Selection' }).should('be.enabled') cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') }) + it('includes inherited guestbooks when the dataset is in a subcollection', () => { + const dataset = DatasetMother.create({ + guestbookId: mockGuestbooks[0].id, + hierarchy: UpwardHierarchyNodeMother.createDataset({ + parent: UpwardHierarchyNodeMother.createSubCollection({ + id: '17', + name: 'SubCollection', + parent: UpwardHierarchyNodeMother.createCollection({ id: 'root', name: 'Root' }) + }) + }) + }) + + cy.customMount( + withProviders(, dataset) + ) + + cy.wrap( + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + ).should('have.been.calledWith', '17', false, true) + }) + it('enables Save Changes when selecting a different guestbook', () => { const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) @@ -148,6 +190,22 @@ describe('EditGuestbook', () => { cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') }) + it('does not show disabled guestbooks in the list', () => { + ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).resolves([ + ...mockGuestbooks, + disabledGuestbook + ]) + const dataset = DatasetMother.create({ guestbookId: undefined }) + + cy.customMount( + withProviders(, dataset) + ) + + cy.findByLabelText('Data Request Guestbook').should('exist') + cy.findByLabelText('Secondary Guestbook').should('exist') + cy.findByLabelText('Disabled Guestbook').should('not.exist') + }) + it('clears the selected guestbook when clicking Clear Selection', () => { const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) diff --git a/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx index 9e43b6594..7925d99e5 100644 --- a/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx @@ -10,8 +10,13 @@ describe('useAssignDatasetGuestbook', () => { beforeEach(() => { onSuccessfulAssignDatasetGuestbook = cy.stub().as('onSuccessfulAssignDatasetGuestbook') guestbookRepository = { + createGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub(), removeDatasetGuestbook: cy.stub() } diff --git a/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx index 038bb086f..fddb589af 100644 --- a/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx @@ -10,8 +10,13 @@ describe('useRemoveDatasetGuestbook', () => { beforeEach(() => { onSuccessfulRemoveDatasetGuestbook = cy.stub().as('onSuccessfulRemoveDatasetGuestbook') guestbookRepository = { + createGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub(), removeDatasetGuestbook: cy.stub() } diff --git a/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx b/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx index 62bb8cb11..ef3cd7483 100644 --- a/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx +++ b/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx @@ -197,8 +197,13 @@ describe('AccessFileMenu', () => { it('opens the guestbook modal before starting the file download', () => { const guestbookRepository: GuestbookRepository = { + createGuestbook: cy.stub().resolves(1), getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook), getGuestbooksByCollectionId: cy.stub().resolves([]), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub().resolves(), removeDatasetGuestbook: cy.stub().resolves() } @@ -328,8 +333,13 @@ describe('AccessFileMenu', () => { it('opens the terms modal when custom terms exist without a guestbook', () => { const guestbookRepository: GuestbookRepository = { + createGuestbook: cy.stub().resolves(1), getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook), getGuestbooksByCollectionId: cy.stub().resolves([]), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub().resolves(), removeDatasetGuestbook: cy.stub().resolves() } diff --git a/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx b/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx new file mode 100644 index 000000000..009507ecb --- /dev/null +++ b/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookActionButtons } from '@/sections/guestbooks/action-buttons/GuestbookActionButtons' +import { PreviewGuestbookModal } from '@/sections/guestbooks/preview-modal/PreviewGuestbookModal' + +const guestbook: Guestbook = { + id: 10, + name: 'Downloadable Guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 17 +} + +const GuestbookActionButtonsTestWrapper = ({ + isEnabled = true, + canToggleEnabled = true, + canEdit = true, + isTogglingEnabled = false, + isDownloadingResponses = false, + onToggleEnabled = () => {}, + onDownloadResponses = () => {} +}: { + isEnabled?: boolean + canToggleEnabled?: boolean + canEdit?: boolean + isTogglingEnabled?: boolean + isDownloadingResponses?: boolean + onToggleEnabled?: () => void + onDownloadResponses?: () => void +}) => { + const [showPreview, setShowPreview] = useState(false) + + return ( + <> + setShowPreview(true)} + onToggleEnabled={onToggleEnabled} + canToggleEnabled={canToggleEnabled} + canEdit={canEdit} + isTogglingEnabled={isTogglingEnabled} + onDownloadResponses={onDownloadResponses} + isDownloadingResponses={isDownloadingResponses} + /> + setShowPreview(false)} + guestbook={guestbook} + /> + + ) +} + +describe('GuestbookActionButtons', () => { + it('renders Disable when guestbook is enabled and triggers toggle handler', () => { + const onToggleEnabled = cy.stub().as('onToggleEnabled') + + cy.customMount() + + cy.findByRole('button', { name: 'Disable' }).click() + cy.get('@onToggleEnabled').should('have.been.calledOnce') + }) + + it('renders Enable when guestbook is disabled', () => { + cy.customMount() + + cy.findByRole('button', { name: 'Enable' }).should('exist') + }) + + it('does not render the toggle button when toggling is not allowed', () => { + cy.customMount() + + cy.findByRole('button', { name: 'Disable' }).should('not.exist') + cy.findByRole('button', { name: 'View' }).should('exist') + cy.findByRole('button', { name: 'Copy' }).should('exist') + cy.findByRole('button', { name: 'Edit' }).should('exist') + cy.findByRole('button', { name: 'Download responses' }).should('exist') + cy.findByRole('button', { name: 'View Responses' }).should('exist') + }) + + it('does not render the edit button when editing is not allowed', () => { + cy.customMount() + + cy.findByRole('button', { name: 'View' }).should('exist') + cy.findByRole('button', { name: 'Copy' }).should('exist') + cy.findByRole('button', { name: 'Edit' }).should('not.exist') + cy.findByRole('button', { name: 'Download responses' }).should('exist') + cy.findByRole('button', { name: 'View Responses' }).should('exist') + }) + + it('opens and closes the preview guestbook modal from the view button', () => { + cy.customMount() + + cy.findByRole('button', { name: 'View' }).click() + cy.findByRole('dialog').should('be.visible') + cy.findByText('Preview Guestbook').should('exist') + cy.findByText('Downloadable Guestbook').should('exist') + cy.findByText(/How will you use this data\?/).should('exist') + cy.findByText('Close').click() + cy.findByRole('dialog').should('not.exist') + }) + + it('triggers download handler', () => { + const onDownloadResponses = cy.stub().as('onDownloadResponses') + + cy.customMount() + + cy.findByRole('button', { name: 'Download responses' }).click() + cy.get('@onDownloadResponses').should('have.been.calledOnce') + }) + + it('uses enabled button defaults when optional loading flags are omitted', () => { + const onToggleEnabled = cy.stub().as('onToggleEnabled') + const onDownloadResponses = cy.stub().as('onDownloadResponses') + + cy.customMount( + {}} + onToggleEnabled={onToggleEnabled} + onDownloadResponses={onDownloadResponses} + /> + ) + + cy.findByRole('button', { name: 'Disable' }).should('not.be.disabled').click() + cy.findByRole('button', { name: 'Download responses' }).should('not.be.disabled').click() + + cy.get('@onToggleEnabled').should('have.been.calledOnce') + cy.get('@onDownloadResponses').should('have.been.calledOnce') + }) + + it('opens the not implemented modal from copy, edit, and view responses buttons', () => { + cy.customMount() + + cy.findByRole('button', { name: 'Copy' }).click() + cy.findByText('Not Implemented').should('exist') + cy.findByText(/This feature is not implemented yet in the Modern version./i).should('exist') + cy.findByText('Close').click() + cy.findByText('Not Implemented').should('not.exist') + + cy.findByRole('button', { name: 'Edit' }).click() + cy.findByText('Not Implemented').should('exist') + cy.findByText('Close').click() + cy.findByText('Not Implemented').should('not.exist') + + cy.findByRole('button', { name: 'View Responses' }).click() + cy.findByText('Not Implemented').should('exist') + }) + + it('keeps toggle and download buttons enabled when loading flags are false', () => { + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Disable' }).should('not.be.disabled') + cy.findByRole('button', { name: 'Download responses' }).should('not.be.disabled') + }) + + it('disables toggle and download buttons while actions are in progress', () => { + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Disable' }).should('be.disabled') + cy.findByRole('button', { name: 'Download responses' }).should('be.disabled') + }) +}) diff --git a/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx b/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx new file mode 100644 index 000000000..3b158ed5f --- /dev/null +++ b/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx @@ -0,0 +1,648 @@ +import { ReactNode, Suspense } from 'react' +import { useTranslation } from 'react-i18next' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { Guestbooks } from '@/sections/guestbooks/ManageGuestbooks' +import { UpwardHierarchyNodeMother } from '@tests/component/shared/hierarchy/domain/models/UpwardHierarchyNodeMother' +import { + DvObjectType, + UpwardHierarchyNode +} from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' +import { WithRepositories } from '@tests/component/WithRepositories' + +describe('ManageGuestbooks', () => { + const collectionRepository = {} as CollectionRepository + let guestbookRepository: GuestbookRepository + + const guestbook: Guestbook = { + id: 10, + name: 'Downloadable Guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 17, + usageCount: 5, + responseCount: 1 + } + const rootGuestbook: Guestbook = { + id: 11, + name: 'Alpha Root Guestbook', + enabled: true, + emailRequired: true, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2025-01-01T00:00:00.000Z', + dataverseId: 1, + usageCount: 1, + responseCount: 2 + } + const localGuestbookLater: Guestbook = { + id: 12, + name: 'zeta local guestbook', + enabled: true, + emailRequired: true, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { question: 'Q1', required: false, displayOrder: 1, type: 'text', hidden: false } + ], + createTime: '2027-01-01T00:00:00.000Z', + dataverseId: 17, + usageCount: 3, + responseCount: 4 + } + const localGuestbookMostQuestions: Guestbook = { + id: 13, + name: 'Beta Local Guestbook', + enabled: false, + emailRequired: true, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { question: 'Q1', required: false, displayOrder: 1, type: 'text', hidden: false }, + { question: 'Q2', required: false, displayOrder: 2, type: 'text', hidden: false } + ], + createTime: '2024-01-01T00:00:00.000Z', + dataverseId: 17, + usageCount: 8, + responseCount: 6 + } + const guestbookWithoutStats: Guestbook = { + id: 14, + name: 'Stats Missing Guestbook', + enabled: true, + emailRequired: false, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-06-01T00:00:00.000Z', + dataverseId: 17 + } + + const TranslationPreloader = ({ children }: { children: ReactNode }) => { + useTranslation('guestbooks') + + return <>{children} + } + + const defaultGuestbooks = [ + guestbook, + rootGuestbook, + localGuestbookLater, + localGuestbookMostQuestions + ] + const currentCollectionGuestbooks = [ + guestbook, + localGuestbookLater, + localGuestbookMostQuestions, + guestbookWithoutStats + ] + + beforeEach(() => { + collectionRepository.getById = cy.stub().resolves( + CollectionMother.create({ + id: '17', + name: 'SubCollection', + hierarchy: UpwardHierarchyNodeMother.createSubCollection({ + id: '17', + name: 'SubCollection', + parent: UpwardHierarchyNodeMother.createCollection({ + id: 'root', + name: 'Root' + }) + }) + }) + ) + + guestbookRepository = { + createGuestbook: cy.stub(), + getGuestbook: cy.stub(), + getGuestbooksByCollectionId: cy + .stub() + .callsFake( + ( + _collectionIdOrAlias: number | string, + _includeStats = false, + includeInherited = false + ) => Promise.resolve(includeInherited ? defaultGuestbooks : currentCollectionGuestbooks) + ), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub().as('setGuestbookEnabled').resolves(undefined), + downloadGuestbookResponsesByCollectionId: cy + .stub() + .as('downloadGuestbookResponsesByCollectionId') + .resolves('name,email\nJane Doe,jane@example.com'), + downloadGuestbookResponsesByGuestbookId: cy + .stub() + .as('downloadGuestbookResponsesByGuestbookId') + .resolves('name,email\nJane Doe,jane@example.com'), + assignDatasetGuestbook: cy.stub(), + removeDatasetGuestbook: cy.stub() + } + + cy.window().then((win) => { + cy.stub(win.URL, 'createObjectURL').returns('blob:guestbook-download') + cy.stub(win.URL, 'revokeObjectURL') + }) + }) + + const mountComponent = (collectionId = '17') => + cy.customMount( + + + + + + + + ) + + const getRenderedGuestbookNames = () => + cy + .get('tbody tr td:first-child') + .then(($cells) => [...$cells].map((cell) => cell.textContent?.trim() ?? '')) + + it('downloads guestbook responses from the guestbook page ui', () => { + const createElementSpy = cy.spy(document, 'createElement') + + mountComponent() + + cy.contains('tbody tr', 'Downloadable Guestbook') + .findByRole('button', { name: 'Download responses' }) + .click() + + cy.get('@downloadGuestbookResponsesByGuestbookId').should('have.been.calledOnceWith', 17, 10) + cy.then(() => { + expect(createElementSpy).to.have.been.calledWith('a') + }) + cy.window().then((win) => { + expect(win.URL['createObjectURL']).to.have.been.called + expect(win.URL['revokeObjectURL']).to.have.been.called + }) + cy.findByText('Your download has started.').should('exist') + }) + + it('sorts guestbooks by name and toggles sort direction on repeated clicks', () => { + mountComponent() + + cy.findByRole('button', { name: /Guestbook Name/i }).click() + cy.findByRole('button', { name: /Guestbook Name/i }) + .should('have.attr', 'aria-pressed', 'true') + .invoke('attr', 'class') + .should('include', 'sort-button-active') + cy.findByRole('button', { name: /Guestbook Name/i }) + .closest('th') + .invoke('attr', 'class') + .should('include', 'sort-header-active') + getRenderedGuestbookNames().should('deep.equal', [ + 'Alpha Root Guestbook', + 'Beta Local Guestbook', + 'Downloadable Guestbook', + 'zeta local guestbook' + ]) + + cy.findByRole('button', { name: /Guestbook Name/i }).click() + getRenderedGuestbookNames().should('deep.equal', [ + 'zeta local guestbook', + 'Downloadable Guestbook', + 'Beta Local Guestbook', + 'Alpha Root Guestbook' + ]) + }) + + it('sorts guestbooks by created date', () => { + mountComponent() + + cy.findByRole('button', { name: /Created/i }).click() + + getRenderedGuestbookNames().should('deep.equal', [ + 'Beta Local Guestbook', + 'Alpha Root Guestbook', + 'Downloadable Guestbook', + 'zeta local guestbook' + ]) + }) + + it('sorts guestbooks by usage count', () => { + mountComponent() + + cy.get('thead') + .findByRole('button', { name: /^Usage$/i }) + .click() + + getRenderedGuestbookNames().should('deep.equal', [ + 'Alpha Root Guestbook', + 'zeta local guestbook', + 'Downloadable Guestbook', + 'Beta Local Guestbook' + ]) + }) + + it('sorts guestbooks by usage count with missing stats treated as zero and toggles direction', () => { + ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).resolves([ + guestbook, + guestbookWithoutStats, + localGuestbookLater + ]) + + mountComponent() + + cy.get('thead') + .findByRole('button', { name: /^Usage$/i }) + .click() + + getRenderedGuestbookNames().should('deep.equal', [ + 'Stats Missing Guestbook', + 'zeta local guestbook', + 'Downloadable Guestbook' + ]) + + cy.contains('tbody tr', 'Stats Missing Guestbook').within(() => { + cy.get('td').eq(2).should('have.text', '0') + cy.get('td').eq(3).should('have.text', '0') + }) + + cy.get('thead') + .findByRole('button', { name: /^Usage$/i }) + .click() + + getRenderedGuestbookNames().should('deep.equal', [ + 'Downloadable Guestbook', + 'zeta local guestbook', + 'Stats Missing Guestbook' + ]) + }) + + it('sorts guestbooks by response count', () => { + mountComponent() + + cy.get('thead') + .findByRole('button', { name: /^Responses$/i }) + .click() + + getRenderedGuestbookNames().should('deep.equal', [ + 'Downloadable Guestbook', + 'Alpha Root Guestbook', + 'zeta local guestbook', + 'Beta Local Guestbook' + ]) + }) + + it('sorts guestbooks by response count with missing stats treated as zero and toggles direction', () => { + ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).resolves([ + guestbook, + guestbookWithoutStats, + localGuestbookLater + ]) + + mountComponent() + + cy.get('thead') + .findByRole('button', { name: /^Responses$/i }) + .click() + + getRenderedGuestbookNames().should('deep.equal', [ + 'Stats Missing Guestbook', + 'Downloadable Guestbook', + 'zeta local guestbook' + ]) + + cy.get('thead') + .findByRole('button', { name: /^Responses$/i }) + .click() + + getRenderedGuestbookNames().should('deep.equal', [ + 'zeta local guestbook', + 'Downloadable Guestbook', + 'Stats Missing Guestbook' + ]) + }) + + it('prefills usage and response counts from the guestbooks stats payload', () => { + mountComponent() + + cy.get('tbody tr') + .eq(0) + .within(() => { + cy.get('td').eq(2).should('have.text', '5') + cy.get('td').eq(3).should('have.text', '1') + }) + + cy.get('tbody tr') + .eq(3) + .within(() => { + cy.get('td').eq(2).should('have.text', '8') + cy.get('td').eq(3).should('have.text', '6') + }) + + cy.wrap( + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + ).should('have.been.calledWith', '17', true, true) + cy.wrap( + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + ).should('have.been.calledWith', '17', false, false) + }) + + it('fetches and filters inherited guestbooks when include guestbooks from parent is toggled', () => { + mountComponent() + + cy.findByLabelText('Include Guestbooks from Root').click() + cy.wrap( + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + ).should('have.been.calledWith', '17', true, false) + cy.findByText('Alpha Root Guestbook').should('not.exist') + cy.findByText('Downloadable Guestbook').should('exist') + cy.findByText('Beta Local Guestbook').should('exist') + + cy.findByLabelText('Include Guestbooks from Root').click() + cy.wrap( + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + ).should('have.been.calledWith', '17', true, true) + cy.findByText('Alpha Root Guestbook').should('exist') + }) + + it('passes includeInherited from the include guestbooks from parent checkbox state', () => { + const getGuestbooksByCollectionIdStub = + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + + mountComponent() + + cy.findByLabelText('Include Guestbooks from Root').should('be.checked') + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledWith', '17', true, true) + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledWith', '17', false, false) + + cy.then(() => { + getGuestbooksByCollectionIdStub.resetHistory() + }) + + cy.findByLabelText('Include Guestbooks from Root').click() + cy.findByLabelText('Include Guestbooks from Root').should('not.be.checked') + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', '17', true, false) + + cy.then(() => { + getGuestbooksByCollectionIdStub.resetHistory() + }) + + cy.findByLabelText('Include Guestbooks from Root').click() + cy.findByLabelText('Include Guestbooks from Root').should('be.checked') + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', '17', true, true) + }) + + it('hides the include guestbooks checkbox at the root collection', () => { + collectionRepository.getById = cy.stub().resolves( + CollectionMother.create({ + id: 'root', + name: 'Root', + hierarchy: UpwardHierarchyNodeMother.createCollection({ + id: 'root', + name: 'Root' + }) + }) + ) + + cy.customMount( + + + + + + + + ) + + cy.findByLabelText('Include Guestbooks from Root').should('not.exist') + }) + + it('uses Parent in the include guestbooks checkbox label when the parent hierarchy name is missing', () => { + const parentNodeWithoutName = new UpwardHierarchyNode( + undefined as unknown as string, + DvObjectType.COLLECTION, + 'root' + ) + const subCollectionNode = new UpwardHierarchyNode( + 'SubCollection', + DvObjectType.COLLECTION, + '17', + undefined, + undefined, + true, + parentNodeWithoutName + ) + collectionRepository.getById = cy.stub().resolves( + CollectionMother.create({ + id: '17', + name: 'SubCollection', + hierarchy: subCollectionNode + }) + ) + + mountComponent() + + cy.findByLabelText('Include Guestbooks from Parent').should('exist') + }) + + it('renders the collection not found page when the collection cannot be fetched', () => { + collectionRepository.getById = cy.stub().rejects(new Error('missing collection')) + + mountComponent() + + cy.findByTestId('not-found-page').should('exist') + cy.findByText(/We can't find the/i).should('exist') + cy.findByText('Collection').should('exist') + }) + + it('opens and closes the preview guestbook modal from the page ui', () => { + mountComponent() + + cy.findAllByRole('button', { name: 'View' }).first().click() + cy.findByRole('dialog').should('be.visible') + cy.findByText('Preview Guestbook').should('exist') + cy.findByText('Close').click() + cy.findByRole('dialog').should('not.exist') + }) + + it('downloads all guestbook responses from the dataverse use case', () => { + const createElementSpy = cy.spy(document, 'createElement') + + mountComponent() + + cy.findByText('Download All Responses').click() + + cy.get('@downloadGuestbookResponsesByCollectionId').should('have.been.calledOnceWith', '17') + cy.then(() => { + expect(createElementSpy).to.have.been.calledWith('a') + }) + cy.window().then((win) => { + expect(win.URL['createObjectURL']).to.have.been.called + expect(win.URL['revokeObjectURL']).to.have.been.called + }) + cy.findByText('Your download has started.').should('exist') + }) + + it('hides download all responses when there are no guestbooks', () => { + ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).resolves( + [] + ) + + mountComponent() + + cy.findByText('Download All Responses').should('not.exist') + cy.findByText('Why Use Guestbooks?').should('exist') + }) + + it('hides download all responses when no guestbook has responses', () => { + ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).resolves([ + { + ...guestbook, + responseCount: 0 + }, + { + ...localGuestbookLater, + responseCount: undefined + } + ]) + + mountComponent() + + cy.findByText('Download All Responses').should('not.exist') + cy.findByText('Downloadable Guestbook').should('exist') + cy.findByText('zeta local guestbook').should('exist') + }) + + it('toggles a guestbook through the setGuestbookEnabled use case and refreshes the table', () => { + mountComponent() + + cy.contains('tbody tr', 'Downloadable Guestbook') + .findByRole('button', { name: 'Disable' }) + .click() + + cy.get('@setGuestbookEnabled').should('have.been.calledOnceWith', 17, 10, false) + cy.contains('tbody tr', 'Downloadable Guestbook') + .findByRole('button', { name: 'Enable' }) + .should('exist') + cy.findByText('The guestbook status has been updated.').should('exist') + }) + + it('only shows view, copy, download, and view responses actions for inherited guestbooks from a parent collection', () => { + mountComponent() + + cy.contains('tbody tr', 'Alpha Root Guestbook').within(() => { + cy.findByText('Guestbook created at root').should('exist') + cy.findByRole('button', { name: 'Disable' }).should('not.exist') + cy.findByRole('button', { name: 'Enable' }).should('not.exist') + cy.findByRole('button', { name: 'View' }).should('exist') + cy.findByRole('button', { name: 'Copy' }).should('exist') + cy.findByRole('button', { name: 'Edit' }).should('not.exist') + cy.findByRole('button', { name: 'Download responses' }).should('exist') + cy.findByRole('button', { name: 'View Responses' }).should('exist') + }) + + cy.contains('tbody tr', 'Downloadable Guestbook').within(() => { + cy.findByRole('button', { name: 'Disable' }).should('exist') + cy.findByRole('button', { name: 'Edit' }).should('exist') + }) + }) + + it('keeps current collection guestbook actions when the collection id is an alias', () => { + collectionRepository.getById = cy.stub().resolves( + CollectionMother.create({ + id: 'subcollection', + name: 'SubCollection', + hierarchy: UpwardHierarchyNodeMother.createSubCollection({ + id: 'subcollection', + name: 'SubCollection', + parent: UpwardHierarchyNodeMother.createCollection({ + id: 'root', + name: 'Root' + }) + }) + }) + ) + + mountComponent('subcollection') + + cy.contains('tbody tr', 'Downloadable Guestbook').within(() => { + cy.findByText(/Guestbook created at/i).should('not.exist') + cy.findByRole('button', { name: 'Disable' }).should('exist') + cy.findByRole('button', { name: 'Edit' }).should('exist') + }) + + cy.contains('tbody tr', 'Alpha Root Guestbook').within(() => { + cy.findByText('Guestbook created at root').should('exist') + cy.findByRole('button', { name: 'Disable' }).should('not.exist') + cy.findByRole('button', { name: 'Edit' }).should('not.exist') + }) + }) + + it('shows an error when toggling guestbook status fails', () => { + ;(guestbookRepository.setGuestbookEnabled as Cypress.Agent).rejects( + new Error('toggle failed') + ) + + mountComponent() + + cy.contains('tbody tr', 'Downloadable Guestbook') + .findByRole('button', { name: 'Disable' }) + .click() + + cy.findByText(/Something went wrong updating the guestbook status. Try again later.*/i).should( + 'exist' + ) + }) + + it('shows an error when guestbook response download fails', () => { + ;( + guestbookRepository.downloadGuestbookResponsesByGuestbookId as Cypress.Agent + ).rejects(new Error('download failed')) + + mountComponent() + + cy.contains('tbody tr', 'Downloadable Guestbook') + .findByRole('button', { name: 'Download responses' }) + .click() + + cy.findByText( + /Something went wrong downloading guestbook responses. Try again later.*/i + ).should('exist') + }) + + it('shows an error when downloading all guestbook responses fails', () => { + ;( + guestbookRepository.downloadGuestbookResponsesByCollectionId as Cypress.Agent + ).rejects(new Error('download failed')) + + mountComponent() + + cy.findByText('Download All Responses').click() + + cy.findByText( + /Something went wrong downloading guestbook responses. Try again later.*/i + ).should('exist') + }) + + it('shows an error alert when fetching guestbooks fails', () => { + ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).rejects( + new Error('unexpected') + ) + + mountComponent() + + cy.findByRole('alert') + .should('exist') + .and( + 'contain.text', + 'Something went wrong getting guestbooks by collection id. Try again later.' + ) + }) +}) diff --git a/tests/component/sections/guestbooks/create-guestbooks/CreateGuestbook.spec.tsx b/tests/component/sections/guestbooks/create-guestbooks/CreateGuestbook.spec.tsx new file mode 100644 index 000000000..dc3b4cb59 --- /dev/null +++ b/tests/component/sections/guestbooks/create-guestbooks/CreateGuestbook.spec.tsx @@ -0,0 +1,254 @@ +import { act, renderHook } from '@testing-library/react' +import { type CreateGuestbookDTO, WriteError } from '@iqss/dataverse-client-javascript' +import { CreateGuestbook } from '@/sections/guestbooks/create-guestbooks/CreateGuestbook' +import { useCreateGuestbook } from '@/sections/guestbooks/create-guestbooks/useCreateGuestbook' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' +import { createGuestbookRepositoryStub } from '../createGuestbookRepositoryStub' +import { WithRepositories } from '@tests/component/WithRepositories' + +type CreateGuestbookStub = sinon.SinonStub< + [collectionIdOrAlias: number | string, guestbook: CreateGuestbookDTO], + Promise +> + +const guestbook: CreateGuestbookDTO = { + name: 'Test Guestbook', + enabled: false, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 0, + type: 'text', + hidden: false + } + ] +} + +describe('CreateGuestbook', () => { + const collectionRepository = {} as CollectionRepository + let guestbookRepository: GuestbookRepository + let createGuestbookStub: Cypress.Agent + + beforeEach(() => { + collectionRepository.getById = cy.stub().resolves( + CollectionMother.create({ + id: 'root', + name: 'Root' + }) + ) + guestbookRepository = createGuestbookRepositoryStub() + createGuestbookStub = guestbookRepository.createGuestbook as Cypress.Agent + createGuestbookStub.as('createGuestbook').resolves(123) + }) + + const mountCreateGuestbook = () => + cy.customMount( + + + + ) + + const expectGuestbookCreatedWith = (expectedGuestbook: CreateGuestbookDTO) => { + cy.wrap(null).should(() => { + expect(createGuestbookStub).to.have.been.calledOnce + + const createGuestbookCall = createGuestbookStub.getCall(0) + expect(createGuestbookCall.args[0]).to.equal('root') + expect(createGuestbookCall.args[1]).to.deep.equal(expectedGuestbook) + }) + } + + it('creates guestbooks as enabled by default', () => { + mountCreateGuestbook() + + cy.get('#guestbook-name').type('Enabled By Default Guestbook') + cy.get('button[type="submit"]').click() + + cy.wrap(null).should(() => { + expect(createGuestbookStub).to.have.been.calledOnce + expect(createGuestbookStub.getCall(0).args[1].enabled).to.equal(true) + }) + }) + + it('submits a guestbook with single line and multiple line custom questions', () => { + mountCreateGuestbook() + + cy.get('#guestbook-name').type('Research Use Guestbook') + + cy.get('select').first().select('text') + cy.get('input[type="text"]').eq(1).type('What is your project name?') + + cy.findByLabelText('Add question').click() + cy.get('select').last().select('textarea') + cy.get('input[type="text"]').eq(2).type('How will you use this data?') + cy.get('#custom-question-required-2').click() + + cy.get('button[type="submit"]').click() + + expectGuestbookCreatedWith({ + name: 'Research Use Guestbook', + enabled: true, + nameRequired: false, + emailRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'What is your project name?', + required: false, + displayOrder: 0, + type: 'text', + hidden: false, + optionValues: undefined + }, + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'textarea', + hidden: false, + optionValues: undefined + } + ] + }) + }) + + it('submits a guestbook with multiline and multiple choice custom questions', () => { + mountCreateGuestbook() + + cy.get('#guestbook-name').type('Research Use Guestbook') + cy.get('#data-collected-name').click() + cy.get('#data-collected-institution').click() + + cy.get('select').first().select('textarea') + cy.get('input[type="text"]').eq(1).type('How will you use this data?') + cy.get('#custom-question-required-1').click() + + cy.findByLabelText('Add question').click() + cy.get('select').last().select('options') + cy.get('input[type="text"]').eq(2).type('Preferred format') + cy.get('input[type="text"]').eq(3).type('CSV') + cy.findByLabelText('Add response option').click() + cy.get('input[type="text"]').eq(4).type('JSON') + + cy.get('button[type="submit"]').click() + + expectGuestbookCreatedWith({ + name: 'Research Use Guestbook', + enabled: true, + nameRequired: true, + emailRequired: false, + institutionRequired: true, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 0, + type: 'textarea', + hidden: false, + optionValues: undefined + }, + { + question: 'Preferred format', + required: false, + displayOrder: 1, + type: 'options', + hidden: false, + optionValues: [ + { value: 'CSV', displayOrder: 0 }, + { value: 'JSON', displayOrder: 1 } + ] + } + ] + }) + }) +}) + +describe('useCreateGuestbook', () => { + let guestbookRepository: GuestbookRepository + let onSuccessfulCreate: Cypress.Agent + + beforeEach(() => { + guestbookRepository = createGuestbookRepositoryStub() + onSuccessfulCreate = cy.stub().as('onSuccessfulCreate') + }) + + it('creates guestbook and calls success callback', async () => { + const createGuestbookStub = + guestbookRepository.createGuestbook as Cypress.Agent + createGuestbookStub.resolves(123) + + const { result } = renderHook(() => + useCreateGuestbook({ + guestbookRepository, + collectionIdOrAlias: 'root', + onSuccessfulCreate + }) + ) + + await act(async () => { + await result.current.handleCreateGuestbook(guestbook) + }) + + expect(createGuestbookStub).to.have.been.calledOnceWith('root', guestbook) + expect(onSuccessfulCreate).to.have.been.calledOnceWith(123) + expect(result.current.errorCreatingGuestbook).to.deep.equal(null) + expect(result.current.isCreatingGuestbook).to.deep.equal(false) + }) + + it('sets formatted error when create fails with WriteError', async () => { + const writeError = new WriteError() + writeError.message = 'Request failed. Reason was: [400] Guestbook name is required' + const createGuestbookStub = + guestbookRepository.createGuestbook as Cypress.Agent + createGuestbookStub.rejects(writeError) + + const { result } = renderHook(() => + useCreateGuestbook({ + guestbookRepository, + collectionIdOrAlias: 'root', + onSuccessfulCreate + }) + ) + + await act(async () => { + await result.current.handleCreateGuestbook(guestbook) + }) + + expect(onSuccessfulCreate).to.not.have.been.called + expect(result.current.errorCreatingGuestbook).to.deep.equal('Guestbook name is required') + expect(result.current.isCreatingGuestbook).to.deep.equal(false) + }) + + it('sets default error when create fails with unknown error', async () => { + const createGuestbookStub = + guestbookRepository.createGuestbook as Cypress.Agent + createGuestbookStub.rejects(new Error('unexpected')) + + const { result } = renderHook(() => + useCreateGuestbook({ + guestbookRepository, + collectionIdOrAlias: 'root', + onSuccessfulCreate + }) + ) + + await act(async () => { + await result.current.handleCreateGuestbook(guestbook) + }) + + expect(onSuccessfulCreate).to.not.have.been.called + expect(result.current.errorCreatingGuestbook).to.deep.equal( + 'Something went wrong creating the guestbook. Try again later.' + ) + expect(result.current.isCreatingGuestbook).to.deep.equal(false) + }) +}) diff --git a/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts b/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts index a2378952b..edbaef67d 100644 --- a/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts +++ b/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts @@ -1,8 +1,13 @@ import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' export const createGuestbookRepositoryStub = (): GuestbookRepository => ({ + createGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), assignDatasetGuestbook: cy.stub(), removeDatasetGuestbook: cy.stub() }) diff --git a/tests/component/sections/guestbooks/preview-modal/PreviewGuestbookModal.spec.tsx b/tests/component/sections/guestbooks/preview-modal/PreviewGuestbookModal.spec.tsx new file mode 100644 index 000000000..8f297ed89 --- /dev/null +++ b/tests/component/sections/guestbooks/preview-modal/PreviewGuestbookModal.spec.tsx @@ -0,0 +1,48 @@ +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { PreviewGuestbookModal } from '@/sections/guestbooks/preview-modal/PreviewGuestbookModal' + +const guestbook: Guestbook = { + id: 3, + name: 'Preview Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + }, + { + question: 'Do you plan to cite this dataset?', + required: false, + displayOrder: 2, + type: 'text', + hidden: false + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 +} + +describe('PreviewGuestbookModal', () => { + it('renders optional labels for account information and custom questions', () => { + const handleClose = cy.stub().as('handleClose') + + cy.customMount() + + cy.findByRole('dialog').should('be.visible') + cy.findByText('Preview Guestbook Test').should('exist') + cy.findByText('Institution (Optional)').should('exist') + cy.findByText('Position (Optional)').should('exist') + cy.findByText(/Do you plan to cite this dataset\?\s+\(Optional\)/).should('exist') + cy.findByText(/How will you use this data\?\s+\(Required\)/).should('exist') + + cy.findByText('Close').click() + cy.get('@handleClose').should('have.been.calledOnce') + }) +}) diff --git a/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx b/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx index 3b36f5ce6..25a215a27 100644 --- a/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx +++ b/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx @@ -15,7 +15,9 @@ const guestbook: Guestbook = { positionRequired: false, customQuestions: [], createTime: '2026-01-01T00:00:00.000Z', - dataverseId: 1 + dataverseId: 1, + usageCount: 2, + responseCount: 4 } describe('useGetGuestbooksByCollectionId', () => { @@ -44,7 +46,7 @@ describe('useGetGuestbooksByCollectionId', () => { expect(result.current.guestbooks).to.deep.equal([guestbook]) }) - cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1) + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1, false, false) }) it('returns an empty array when request succeeds with a non-array payload', async () => { @@ -66,7 +68,49 @@ describe('useGetGuestbooksByCollectionId', () => { expect(result.current.guestbooks).to.deep.equal([]) }) - cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1) + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1, false, false) + }) + + it('passes includeStats=true when explicitly requested', async () => { + const getGuestbooksByCollectionIdStub = + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + getGuestbooksByCollectionIdStub.resolves([guestbook]) + + const { result } = renderHook(() => + useGetGuestbooksByCollectionId({ + guestbookRepository, + collectionIdOrAlias: 1, + includeStats: true + }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(false) + expect(result.current.guestbooks).to.deep.equal([guestbook]) + }) + + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1, true, false) + }) + + it('passes includeInherited=true when explicitly requested', async () => { + const getGuestbooksByCollectionIdStub = + guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent + getGuestbooksByCollectionIdStub.resolves([guestbook]) + + const { result } = renderHook(() => + useGetGuestbooksByCollectionId({ + guestbookRepository, + collectionIdOrAlias: 1, + includeInherited: true + }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(false) + expect(result.current.guestbooks).to.deep.equal([guestbook]) + }) + + cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1, false, true) }) it('does not fetch when collection id is undefined', async () => { diff --git a/tests/e2e-integration/e2e/sections/guestbooks/ManageGuestbooks.spec.ts b/tests/e2e-integration/e2e/sections/guestbooks/ManageGuestbooks.spec.ts new file mode 100644 index 000000000..a00d03fd8 --- /dev/null +++ b/tests/e2e-integration/e2e/sections/guestbooks/ManageGuestbooks.spec.ts @@ -0,0 +1,106 @@ +import { FRONTEND_BASE_PATH } from '@tests/e2e-integration/shared/basePath' +import { TestsUtils } from '../../../shared/TestsUtils' +import { GuestbookHelper } from '../../../shared/guestbooks/GuestbookHelper' + +const GUESTBOOKS_PAGE_URL = `${FRONTEND_BASE_PATH}/root/guestbooks` + +describe('Manage Guestbooks', () => { + beforeEach(() => { + TestsUtils.login().then((token) => { + cy.wrap(TestsUtils.setup(token)) + }) + }) + + const closeNotImplementedModal = () => { + cy.findByRole('dialog').within(() => { + cy.findByText('Not Implemented').should('exist') + cy.findByText(/This feature is not implemented yet in the Modern version./i).should('exist') + }) + cy.findByText('Close').should('exist').click() + cy.findByRole('dialog').should('not.exist') + } + + it('visits the Manage Guestbooks page and manages a guestbook', () => { + const guestbookName = `E2E Manage Guestbook ${Date.now()}` + + cy.wrap(GuestbookHelper.createAndGetByName(guestbookName), { timeout: 10000 }).then(() => { + cy.visit(GUESTBOOKS_PAGE_URL) + + cy.findByRole('link', { name: 'Root' }) + .closest('.breadcrumb') + .within(() => { + cy.findByText('Dataset Guestbooks').should('exist') + }) + cy.findByRole('heading', { name: 'Root' }).should('exist') + cy.findByRole('button', { name: 'Create Dataset Guestbook' }).should('exist') + + cy.contains('tbody tr', guestbookName).within(() => { + cy.findByRole('button', { name: 'Disable' }).should('exist') + cy.findByRole('button', { name: 'View' }).click() + }) + + cy.findByRole('dialog').within(() => { + cy.findByText('Preview Guestbook').should('exist') + cy.findByText(guestbookName).should('exist') + cy.findByText('Email (Required)').should('exist') + cy.findByText('Name (Required)').should('exist') + cy.findByText('Close').should('exist').click() + }) + + cy.contains('tbody tr', guestbookName).findByRole('button', { name: 'Disable' }).click() + + cy.findByText('The guestbook status has been updated.').should('exist') + cy.contains('tbody tr', guestbookName) + .findByRole('button', { name: 'Enable' }) + .should('exist') + }) + }) + + it('navigates to the create guestbook page from the create button', () => { + cy.visit(GUESTBOOKS_PAGE_URL) + + cy.findByRole('button', { name: 'Create Dataset Guestbook' }).click() + + cy.url().should('include', `${FRONTEND_BASE_PATH}/root/guestbooks/create`) + cy.findByLabelText(/^Guestbook Name/).should('exist') + cy.findByRole('button', { name: 'Create Dataset Guestbook' }).should('exist') + }) + + it('opens the not implemented modal from copy, edit, and view responses actions', () => { + const guestbookName = `E2E Manage Guestbook Actions ${Date.now()}` + + cy.wrap(GuestbookHelper.createAndGetByName(guestbookName), { timeout: 10000 }).then(() => { + cy.visit(GUESTBOOKS_PAGE_URL) + + cy.contains('tbody tr', guestbookName).findByRole('button', { name: 'Copy' }).click() + closeNotImplementedModal() + + cy.contains('tbody tr', guestbookName).findByRole('button', { name: 'Edit' }).click() + closeNotImplementedModal() + + cy.contains('tbody tr', guestbookName) + .findByRole('button', { name: 'View Responses' }) + .click() + closeNotImplementedModal() + }) + }) + + it('starts a guestbook responses download from the row download action', () => { + const guestbookName = `E2E Manage Guestbook Download ${Date.now()}` + + cy.wrap(GuestbookHelper.createAndGetByName(guestbookName), { timeout: 10000 }).then(() => { + cy.visit(GUESTBOOKS_PAGE_URL) + + cy.window().then((window) => { + cy.stub(window.URL, 'createObjectURL').returns('blob:e2e-guestbook-download') + cy.stub(window.URL, 'revokeObjectURL') + }) + + cy.contains('tbody tr', guestbookName) + .findByRole('button', { name: 'Download responses' }) + .click() + + cy.findByText('Your download has started.').should('exist') + }) + }) +})