diff --git a/CHANGELOG.md b/CHANGELOG.md index d51ac33c7..94fe06355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ 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 + - a guestbooks table with sorting, edit, enable/disable actions, preview, and per-guestbook response download - Create Guestbook and Download All Responses actions - - the Create Guestbook page + - the Create and Edit Guestbook pages - a checkbox for including guestbooks from parent collections - Dataset Templates UI integration, including create/edit flows, previews, and skeleton states. diff --git a/package-lock.json b/package-lock.json index fd45160e3..b1e8be70b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "v2.2.0-alpha.9", + "@iqss/dataverse-client-javascript": "2.2.0-alpha.11", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -1953,9 +1953,9 @@ } }, "node_modules/@iqss/dataverse-client-javascript": { - "version": "2.2.0-alpha.9", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.2.0-alpha.9/56c08b2c94d32ffb79a3374b9607651363124fac", - "integrity": "sha512-D7ISknUYo1WFz93KzcLhmrIvqtZiAQX0loRppDrfAQgJoE5FHquFd2QnFQ0+jhism2v26CgZ1oqyYFP+p2BVFQ==", + "version": "2.2.0-alpha.11", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.2.0-alpha.11/3a0a53022fcd9c9dd88f15815a5d1ad46b38a40a", + "integrity": "sha512-stBrA0fG2rHcNhyX8+43CeNPo8YyzZ1h5cKSOxLKAnKGBeF99FaTNhjxxzCQ5r2RlXzLw8EHZ5day0aL/0vDgA==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 98023586e..647c5dad5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "v2.2.0-alpha.9", + "@iqss/dataverse-client-javascript": "2.2.0-alpha.11", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/public/locales/en/datasetTemplates.json b/public/locales/en/datasetTemplates.json index 7f70cc561..b86d1a80d 100644 --- a/public/locales/en/datasetTemplates.json +++ b/public/locales/en/datasetTemplates.json @@ -8,7 +8,7 @@ "create": "Create Dataset Template", "makeDefault": "Make Default", "default": "Default", - "view": "View", + "view": "Preview", "copy": "Copy", "edit": "Edit Template", "delete": "Delete", diff --git a/public/locales/en/guestbooks.json b/public/locales/en/guestbooks.json index 447e5953b..6fb047787 100644 --- a/public/locales/en/guestbooks.json +++ b/public/locales/en/guestbooks.json @@ -8,20 +8,24 @@ "enable": "Enable", "disable": "Disable", "downloadAllResponses": "Download All Responses", - "view": "View", + "view": "Preview", "copy": "Copy", "edit": "Edit", + "delete": "Delete", "downloadResponses": "Download responses", "viewResponses": "View Responses" }, "errors": { "getGuestbook": "Something went wrong getting the guestbook. Try again later.", + "editGuestbook": "Something went wrong editing the guestbook. Try again later.", + "getGuestbookResponses": "Something went wrong getting guestbook responses. 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." + "downloadStarted": "Your download has started.", + "updated": "The guestbook has been updated." }, "preview": { "title": "Preview Guestbook", @@ -94,5 +98,31 @@ }, "submit": "Create Dataset Guestbook", "cancel": "Cancel" + }, + "edit": { + "title": "Edit Dataset Guestbook" + }, + "responses": { + "title": "Guestbook Responses", + "info": "Click \"Download Responses\" to download all collected responses for this guestbook, as a CSV file. To navigate and analyze your collected responses, we recommend importing this CSV file into Excel, Google Sheets or similar software.", + "guestbookName": "Guestbook Name", + "downloadButton": "Download Responses", + "count_one": "{{count}} Response", + "count_other": "{{count}} Responses", + "noRecords": "No records found.", + "table": { + "dataset": "Dataset", + "date": "Date", + "type": "Type", + "file": "File", + "user": "User", + "customQuestions": "Custom Questions" + }, + "eventTypes": { + "AccessRequest": "Access Request", + "Download": "Download", + "Subset": "Subset", + "Explore": "Explore" + } } } diff --git a/public/locales/es/datasetTemplates.json b/public/locales/es/datasetTemplates.json index 13bb0c65d..77367f565 100644 --- a/public/locales/es/datasetTemplates.json +++ b/public/locales/es/datasetTemplates.json @@ -8,7 +8,7 @@ "create": "Crear plantilla de conjunto de datos", "makeDefault": "Hacer predeterminada", "default": "Predeterminada", - "view": "Ver", + "view": "Vista previa", "copy": "Copiar", "edit": "Editar plantilla", "delete": "Eliminar" diff --git a/public/locales/es/guestbooks.json b/public/locales/es/guestbooks.json index e9c7fbd32..a53553fb3 100644 --- a/public/locales/es/guestbooks.json +++ b/public/locales/es/guestbooks.json @@ -8,20 +8,24 @@ "enable": "Habilitar", "disable": "Deshabilitar", "downloadAllResponses": "Descargar todas las respuestas", - "view": "Ver", + "view": "Vista previa", "copy": "Copiar", "edit": "Editar", + "delete": "Eliminar", "downloadResponses": "Descargar respuestas", "viewResponses": "Ver respuestas" }, "errors": { "getGuestbook": "Algo salió mal al obtener el libro de visitas. Inténtalo de nuevo más tarde.", + "editGuestbook": "Algo salió mal al editar el libro de visitas. Inténtalo de nuevo más tarde.", + "getGuestbookResponses": "Algo salió mal al obtener las respuestas del 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." + "downloadStarted": "La descarga ha comenzado.", + "updated": "El libro de visitas se ha actualizado." }, "preview": { "title": "Vista previa del libro de visitas", @@ -94,5 +98,31 @@ }, "submit": "Crear libro de visitas del dataset", "cancel": "Cancelar" + }, + "edit": { + "title": "Editar libro de visitas del dataset" + }, + "responses": { + "title": "Respuestas del libro de visitas", + "info": "Haz clic en \"Descargar respuestas\" para descargar todas las respuestas recopiladas para este libro de visitas como archivo CSV. Para navegar y analizar las respuestas recopiladas, recomendamos importar este archivo CSV en Excel, Google Sheets o software similar.", + "guestbookName": "Nombre del libro de visitas", + "downloadButton": "Descargar respuestas", + "count_one": "{{count}} respuesta", + "count_other": "{{count}} respuestas", + "noRecords": "No se encontraron registros.", + "table": { + "dataset": "Dataset", + "date": "Fecha", + "type": "Tipo", + "file": "Fichero", + "user": "Usuario", + "customQuestions": "Preguntas personalizadas" + }, + "eventTypes": { + "AccessRequest": "Solicitud de acceso", + "Download": "Descarga", + "Subset": "Subconjunto", + "Explore": "Explorar" + } } } diff --git a/src/guestbooks/domain/models/Guestbook.ts b/src/guestbooks/domain/models/Guestbook.ts index cc9744986..899ef793a 100644 --- a/src/guestbooks/domain/models/Guestbook.ts +++ b/src/guestbooks/domain/models/Guestbook.ts @@ -1,12 +1,13 @@ export type GuestbookQuestionType = 'text' | 'textarea' | 'options' export interface GuestbookOption { + id?: number value: string displayOrder: number } export interface GuestbookCustomQuestion { - id?: number | string + id?: number question: string required: boolean displayOrder: number diff --git a/src/guestbooks/domain/models/GuestbookResponse.ts b/src/guestbooks/domain/models/GuestbookResponse.ts index 6d915f8fd..e644ef6b6 100644 --- a/src/guestbooks/domain/models/GuestbookResponse.ts +++ b/src/guestbooks/domain/models/GuestbookResponse.ts @@ -4,13 +4,13 @@ export interface GuestbookResponse { datasetPid: string date: string type: EventType - fileName?: string + email?: string fileId?: number + fileName?: string filePid?: string - userName: string - email?: string institution?: string position?: string + userName: string customQuestions?: GuestbookResponseCustomQuestion[] } diff --git a/src/guestbooks/domain/models/GuestbookResponsesPaginationInfo.ts b/src/guestbooks/domain/models/GuestbookResponsesPaginationInfo.ts new file mode 100644 index 000000000..f494a2871 --- /dev/null +++ b/src/guestbooks/domain/models/GuestbookResponsesPaginationInfo.ts @@ -0,0 +1,7 @@ +import { PaginationInfo } from '@/shared/pagination/domain/models/PaginationInfo' + +export class GuestbookResponsesPaginationInfo extends PaginationInfo { + constructor(page = 1, pageSize = 10, totalItems = 0, itemName = 'Response') { + super(page, pageSize, totalItems, itemName) + } +} diff --git a/src/guestbooks/domain/repositories/GuestbookRepository.ts b/src/guestbooks/domain/repositories/GuestbookRepository.ts index 1da6afee4..d2f1966b3 100644 --- a/src/guestbooks/domain/repositories/GuestbookRepository.ts +++ b/src/guestbooks/domain/repositories/GuestbookRepository.ts @@ -1,12 +1,13 @@ -import { type CreateGuestbookDTO } from '@iqss/dataverse-client-javascript' +import { GuestbookDTO } from '../useCases/DTOs/GuestbookDTO' import { Guestbook } from '../models/Guestbook' import { GuestbookResponseSubset } from '../models/GuestbookResponse' export interface GuestbookRepository { createGuestbook: ( collectionIdOrAlias: number | string, - guestbook: CreateGuestbookDTO + guestbook: GuestbookDTO ) => Promise + editGuestbook: (guestbookId: number, guestbook: GuestbookDTO) => Promise getGuestbook: (guestbookId: number) => Promise getGuestbooksByCollectionId: ( collectionIdOrAlias: number | string, diff --git a/src/guestbooks/domain/useCases/DTOs/GuestbookDTO.ts b/src/guestbooks/domain/useCases/DTOs/GuestbookDTO.ts new file mode 100644 index 000000000..5b7bef045 --- /dev/null +++ b/src/guestbooks/domain/useCases/DTOs/GuestbookDTO.ts @@ -0,0 +1,28 @@ +import { GuestbookQuestionType } from '../../models/Guestbook' + +export interface GuestbookDTO { + name: string + enabled: boolean + emailRequired: boolean + nameRequired: boolean + institutionRequired: boolean + positionRequired: boolean + createTime?: string + customQuestions?: GuestbookCustomQuestionDTO[] +} + +export interface GuestbookCustomQuestionDTO { + id?: number + question: string + required: boolean + displayOrder: number + type: GuestbookQuestionType + hidden: boolean + optionValues?: GuestbookOptionDTO[] +} + +export interface GuestbookOptionDTO { + id?: number + value: string + displayOrder: number +} diff --git a/src/guestbooks/domain/useCases/createGuestbook.ts b/src/guestbooks/domain/useCases/createGuestbook.ts index 905d630cc..7d967f8a9 100644 --- a/src/guestbooks/domain/useCases/createGuestbook.ts +++ b/src/guestbooks/domain/useCases/createGuestbook.ts @@ -1,10 +1,10 @@ -import { type CreateGuestbookDTO } from '@iqss/dataverse-client-javascript' import { GuestbookRepository } from '../repositories/GuestbookRepository' +import { GuestbookDTO } from './DTOs/GuestbookDTO' export function createGuestbook( guestbookRepository: GuestbookRepository, collectionIdOrAlias: number | string, - guestbook: CreateGuestbookDTO + guestbook: GuestbookDTO ): Promise { return guestbookRepository.createGuestbook(collectionIdOrAlias, guestbook) } diff --git a/src/guestbooks/domain/useCases/editGuestbook.ts b/src/guestbooks/domain/useCases/editGuestbook.ts new file mode 100644 index 000000000..fe7c49762 --- /dev/null +++ b/src/guestbooks/domain/useCases/editGuestbook.ts @@ -0,0 +1,10 @@ +import { GuestbookRepository } from '../repositories/GuestbookRepository' +import { GuestbookDTO } from './DTOs/GuestbookDTO' + +export function editGuestbook( + guestbookRepository: GuestbookRepository, + guestbookId: number, + guestbook: GuestbookDTO +): Promise { + return guestbookRepository.editGuestbook(guestbookId, guestbook) +} diff --git a/src/guestbooks/domain/useCases/getGuestbooksByCollectionId.ts b/src/guestbooks/domain/useCases/getGuestbooksByCollectionId.ts new file mode 100644 index 000000000..2c2fcf05e --- /dev/null +++ b/src/guestbooks/domain/useCases/getGuestbooksByCollectionId.ts @@ -0,0 +1,15 @@ +import { Guestbook } from '../models/Guestbook' +import { GuestbookRepository } from '../repositories/GuestbookRepository' + +export function getGuestbooksByCollectionId( + guestbookRepository: GuestbookRepository, + collectionIdOrAlias: number | string, + includeStats?: boolean, + includeInherited?: boolean +): Promise { + return guestbookRepository.getGuestbooksByCollectionId( + collectionIdOrAlias, + includeStats, + includeInherited + ) +} diff --git a/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts b/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts index 2cd205084..46f3925f9 100644 --- a/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts +++ b/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts @@ -3,7 +3,9 @@ import { createGuestbook, downloadGuestbookResponsesByCollectionId, downloadGuestbookResponsesOfAGuestbook as downloadGuestbookResponsesByGuestbookId, - type CreateGuestbookDTO, + type CreateGuestbookDTO as JSDataverseCreateGuestbookDTO, + editGuestbook, + type EditGuestbookDTO as JSDataverseEditGuestbookDTO, type GuestbookResponseSubset, getGuestbooksByCollectionId, getGuestbook, @@ -13,17 +15,26 @@ import { } from '@iqss/dataverse-client-javascript' import { GuestbookRepository } from '../../domain/repositories/GuestbookRepository' import { Guestbook } from '../../domain/models/Guestbook' +import { GuestbookDTO } from '../../domain/useCases/DTOs/GuestbookDTO' + +const normalizeGuestbook = (guestbook: Guestbook): Guestbook => ({ + ...guestbook, + customQuestions: guestbook.customQuestions ?? [] +}) export class GuestbookJSDataverseRepository implements GuestbookRepository { - createGuestbook( - collectionIdOrAlias: number | string, - guestbook: CreateGuestbookDTO - ): Promise { - return createGuestbook.execute(guestbook, collectionIdOrAlias) + createGuestbook(collectionIdOrAlias: number | string, guestbook: GuestbookDTO): Promise { + return createGuestbook.execute(guestbook as JSDataverseCreateGuestbookDTO, collectionIdOrAlias) + } + + editGuestbook(guestbookId: number, guestbook: GuestbookDTO): Promise { + return editGuestbook.execute(guestbookId, guestbook as JSDataverseEditGuestbookDTO) } getGuestbook(guestbookId: number): Promise { - return getGuestbook.execute(guestbookId).then((guestbook) => guestbook as Guestbook) + return getGuestbook + .execute(guestbookId) + .then((guestbook) => normalizeGuestbook(guestbook as Guestbook)) } getGuestbooksByCollectionId( @@ -33,7 +44,7 @@ export class GuestbookJSDataverseRepository implements GuestbookRepository { ): Promise { return getGuestbooksByCollectionId .execute(collectionIdOrAlias, includeStats, includeInherited) - .then((guestbooks) => guestbooks as Guestbook[]) + .then((guestbooks) => (guestbooks as Guestbook[]).map(normalizeGuestbook)) } getGuestbookResponsesByGuestbookId( diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 37d52d9fe..a34885529 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -150,6 +150,22 @@ const CreateGuestbookPage = lazy(() => ) ) +const EditGuestbookPage = lazy(() => + import('../sections/guestbooks/edit-guestbook/EditGuestbookFactory').then( + ({ EditGuestbookFactory }) => ({ + default: () => EditGuestbookFactory.create() + }) + ) +) + +const GuestbookResponsesPage = lazy(() => + import('../sections/guestbooks/view-responses/GuestbookResponsesFactory').then( + ({ GuestbookResponsesFactory }) => ({ + default: () => GuestbookResponsesFactory.create() + }) + ) +) + const NotFoundPage = lazy(() => import('../sections/not-found-page/NotFoundPageFactory').then(({ NotFoundPageFactory }) => ({ default: () => NotFoundPageFactory.create() @@ -356,6 +372,24 @@ export const routes: RouteObject[] = [ ), errorElement: }, + { + path: Route.GUESTBOOKS_EDIT, + element: ( + }> + + + ), + errorElement: + }, + { + path: Route.GUESTBOOKS_RESPONSES, + element: ( + }> + + + ), + errorElement: + }, { path: Route.TEMPLATES_CREATE, element: ( diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 642498b6c..80deee503 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -23,6 +23,8 @@ export enum Route { COLLECTION_TEMPLATES = '/:collectionId/templates', GUESTBOOKS = '/:collectionId/guestbooks', GUESTBOOKS_CREATE = '/:collectionId/guestbooks/create', + GUESTBOOKS_EDIT = '/:collectionId/guestbooks/:guestbookId/edit', + GUESTBOOKS_RESPONSES = '/:collectionId/guestbooks/:guestbookId/responses', TEMPLATES_CREATE = '/:collectionId/templates/create', TEMPLATES_EDIT_METADATA = '/:collectionId/templates/:templateId/edit/metadata', TEMPLATES_EDIT_TERMS = '/:collectionId/templates/:templateId/edit/terms', @@ -43,6 +45,10 @@ export const RouteWithParams = { COLLECTION_TEMPLATES: (collectionId: string) => `/${collectionId}/templates`, GUESTBOOKS: (collectionId: string) => `/${collectionId}/guestbooks`, GUESTBOOKS_CREATE: (collectionId: string) => `/${collectionId}/guestbooks/create`, + GUESTBOOKS_EDIT: (collectionId: string, guestbookId: number | string) => + `/${collectionId}/guestbooks/${guestbookId}/edit`, + GUESTBOOKS_RESPONSES: (collectionId: string, guestbookId: number | string) => + `/${collectionId}/guestbooks/${guestbookId}/responses`, 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/CollectionCard.module.scss b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.module.scss index f07a3d791..0ae6c774b 100644 --- a/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.module.scss +++ b/src/sections/collection/collection-items-panel/items-list/collection-card/CollectionCard.module.scss @@ -19,6 +19,9 @@ a { @include link-hover-underlined; + + text-decoration: underline; + text-underline-offset: 2px; } .affiliation { @@ -80,6 +83,9 @@ a { @include link-hover-underlined; + + text-decoration: underline; + text-underline-offset: 2px; } .description { 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 9956e9d31..3e7b614b7 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 @@ -159,7 +159,7 @@ export function DownloadWithTermsAndGuestbookModal({ institution?: string position?: string } - const questionWithId = question as GuestbookCustomQuestion & { id?: number | string } + const questionWithId = question as GuestbookCustomQuestion & { id?: number } if (questionWithId?.id !== undefined) { return questionWithId.id diff --git a/src/sections/guestbooks/Guestbooks.module.scss b/src/sections/guestbooks/Guestbooks.module.scss index 4ed8768df..f5092412e 100644 --- a/src/sections/guestbooks/Guestbooks.module.scss +++ b/src/sections/guestbooks/Guestbooks.module.scss @@ -47,6 +47,33 @@ white-space: nowrap; } +.response-details { + margin: $spacer 0; +} + +.response-detail-row { + display: grid; + grid-template-columns: minmax(10rem, 18rem) minmax(0, 1fr); + gap: 1rem; + align-items: baseline; +} + +.response-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin: $spacer 0; +} + +.no-records-cell { + text-align: left; +} + +.custom-questions-cell { + white-space: pre-line; +} + @media (max-width: 576px) { .table-actions { flex-direction: column; @@ -72,6 +99,16 @@ width: 100%; justify-content: center; } + + .response-detail-row { + grid-template-columns: 1fr; + gap: 0.25rem; + } + + .response-actions { + align-items: stretch; + flex-direction: column; + } } .create-button { diff --git a/src/sections/guestbooks/ManageGuestbooks.tsx b/src/sections/guestbooks/ManageGuestbooks.tsx index fa250be7c..539d9059a 100644 --- a/src/sections/guestbooks/ManageGuestbooks.tsx +++ b/src/sections/guestbooks/ManageGuestbooks.tsx @@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next' import { Button, Form, Table, Alert } from '@iqss/dataverse-design-system' import { CaretDown, CaretUp, ChevronExpand, Download } from 'react-bootstrap-icons' import { toast } from 'react-toastify' +import { useNavigate } from 'react-router-dom' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { Guestbook } from '@/guestbooks/domain/models/Guestbook' import { downloadGuestbookResponsesByCollectionId } from '@/guestbooks/domain/useCases/downloadGuestbookResponsesByCollectionId' import { downloadGuestbookResponsesByGuestbookId } from '@/guestbooks/domain/useCases/downloadGuestbookResponsesByGuestbookId' import { setGuestbookEnabled } from '@/guestbooks/domain/useCases/setGuestbookEnabled' +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' @@ -30,6 +32,7 @@ interface GuestbooksProps { export const Guestbooks = ({ collectionRepository, collectionId }: GuestbooksProps) => { const { t } = useTranslation('guestbooks') + const navigate = useNavigate() const [includeGuestbooksFromParent, setIncludeGuestbooksFromParent] = useState(true) const [sortBy, setSortBy] = useState<'name' | 'created' | 'usage' | 'responses' | null>(null) const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') @@ -374,6 +377,17 @@ export const Guestbooks = ({ collectionRepository, collectionId }: GuestbooksPro setGuestbookToPreview(guestbook)} + onCopy={() => + navigate(RouteWithParams.GUESTBOOKS_CREATE(collectionId), { + state: { guestbookToCopy: guestbook } + }) + } + onEdit={() => + navigate(RouteWithParams.GUESTBOOKS_EDIT(collectionId, guestbook.id)) + } + onViewResponses={() => + navigate(RouteWithParams.GUESTBOOKS_RESPONSES(collectionId, guestbook.id)) + } onToggleEnabled={() => handleToggleEnabled(guestbook)} canToggleEnabled={isGuestbookFromCurrentCollection(guestbook)} canEdit={isGuestbookFromCurrentCollection(guestbook)} diff --git a/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx b/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx index 31f39486b..c9ef7a799 100644 --- a/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx +++ b/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx @@ -1,13 +1,16 @@ 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 { Download, Eye, Files, Pencil, Trash } from 'react-bootstrap-icons' import { NotImplementedModal } from '@/sections/not-implemented/NotImplementedModal' interface GuestbookActionButtonsProps { isEnabled: boolean onView: () => void onToggleEnabled: () => void + onCopy?: () => void + onEdit?: () => void + onViewResponses?: () => void canToggleEnabled?: boolean canEdit?: boolean isTogglingEnabled?: boolean @@ -21,6 +24,9 @@ export const GuestbookActionButtons = ({ isEnabled, onView, onToggleEnabled, + onCopy, + onEdit, + onViewResponses, canToggleEnabled = true, canEdit = true, isTogglingEnabled = false, @@ -51,21 +57,13 @@ export const GuestbookActionButtons = ({ - {canEdit && ( - @@ -80,10 +78,22 @@ export const GuestbookActionButtons = ({ + {!isEnabled && ( + + + + )} diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx index 566f7dd09..43b916147 100644 --- a/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx +++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx @@ -1,11 +1,10 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo } 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 { Alert } from '@iqss/dataverse-design-system' +import { type NavigateFunction, useLocation, useNavigate } from 'react-router-dom' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' -import { GuestbookQuestionType } from '@/guestbooks/domain/models/Guestbook' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' import { RouteWithParams } from '@/sections/Route.enum' import { useCollection } from '@/sections/collection/useCollection' import { NotFoundPage } from '@/sections/not-found-page/NotFoundPage' @@ -13,46 +12,29 @@ import { BreadcrumbsGenerator } from '@/sections/shared/hierarchy/BreadcrumbsGen import { useLoading } from '@/shared/contexts/loading/LoadingContext' import { useGuestbookRepository } from '../GuestbookRepositoryContext' import { GuestbookSkeleton } from '../GuestbookSkeleton' +import { GuestbookForm } from '../guestbook-form/GuestbookForm' 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[] +interface CreateGuestbookLocationState { + guestbookToCopy?: Guestbook } export const CreateGuestbook = ({ collectionId, collectionRepository }: CreateGuestbookProps) => { const { t } = useTranslation('guestbooks') const navigate: NavigateFunction = useNavigate() + const location = useLocation() const guestbookRepository = useGuestbookRepository() 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 guestbookToCopy = (location.state as CreateGuestbookLocationState | null)?.guestbookToCopy const guestbooksGuideUrl = 'https://guides.dataverse.org/en/latest/user/dataverse-management.html#dataset-guestbooks' - const guestbooksRoute = RouteWithParams.GUESTBOOKS(collectionId) - const navigateToGuestbooks = () => navigate(guestbooksRoute) + const navigateToGuestbooks = () => navigate(RouteWithParams.GUESTBOOKS(collectionId)) const { isCreatingGuestbook, errorCreatingGuestbook, handleCreateGuestbook } = useCreateGuestbook( { guestbookRepository, @@ -60,99 +42,38 @@ export const CreateGuestbook = ({ collectionId, collectionRepository }: CreateGu onSuccessfulCreate: navigateToGuestbooks } ) + const initialGuestbook = useMemo( + () => + guestbookToCopy + ? { + name: `Copy of ${guestbookToCopy.name}`, + enabled: guestbookToCopy.enabled, + emailRequired: guestbookToCopy.emailRequired, + nameRequired: guestbookToCopy.nameRequired, + institutionRequired: guestbookToCopy.institutionRequired, + positionRequired: guestbookToCopy.positionRequired, + customQuestions: guestbookToCopy.customQuestions.map((question, index) => ({ + question: question.question, + required: question.required, + displayOrder: index, + type: question.type, + hidden: question.hidden, + optionValues: question.optionValues?.map((option, optionIndex) => ({ + value: option.value, + displayOrder: optionIndex + })) + })) + } + : undefined, + [guestbookToCopy] + ) 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()) + const handleSubmit = (guestbook: GuestbookDTO) => { + void handleCreateGuestbook(guestbook) } if (!isLoading && !collection) { @@ -192,218 +113,14 @@ export const CreateGuestbook = ({ collectionId, collectionRepository }: CreateGu {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/useCreateGuestbook.ts b/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts index 03f348d3b..a26e5e6c8 100644 --- a/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts +++ b/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts @@ -1,5 +1,6 @@ import { useState } from 'react' -import { type CreateGuestbookDTO, WriteError } from '@iqss/dataverse-client-javascript' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' import { createGuestbook } from '@/guestbooks/domain/useCases/createGuestbook' import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' @@ -18,7 +19,7 @@ export const useCreateGuestbook = ({ const [isCreatingGuestbook, setIsCreatingGuestbook] = useState(false) const [errorCreatingGuestbook, setErrorCreatingGuestbook] = useState(null) - const handleCreateGuestbook = async (guestbook: CreateGuestbookDTO) => { + const handleCreateGuestbook = async (guestbook: GuestbookDTO) => { setIsCreatingGuestbook(true) setErrorCreatingGuestbook(null) diff --git a/src/sections/guestbooks/edit-guestbook/EditGuestbook.tsx b/src/sections/guestbooks/edit-guestbook/EditGuestbook.tsx new file mode 100644 index 000000000..c0a92c152 --- /dev/null +++ b/src/sections/guestbooks/edit-guestbook/EditGuestbook.tsx @@ -0,0 +1,114 @@ +import { useEffect } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Alert } from '@iqss/dataverse-design-system' +import { type NavigateFunction, useNavigate } from 'react-router-dom' +import { toast } from 'react-toastify' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' +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 { useGetGuestbookById } from '@/sections/dataset/dataset-guestbook/useGetGuestbookById' +import { useGuestbookRepository } from '../GuestbookRepositoryContext' +import { GuestbookSkeleton } from '../GuestbookSkeleton' +import { GuestbookForm } from '../guestbook-form/GuestbookForm' +import { useEditGuestbook } from './useEditGuestbook' + +interface EditGuestbookProps { + collectionId: string + guestbookId: number + collectionRepository: CollectionRepository +} + +export const EditGuestbook = ({ + collectionId, + guestbookId, + collectionRepository +}: EditGuestbookProps) => { + const { t } = useTranslation('guestbooks') + const { t: tShared } = useTranslation('shared') + const navigate: NavigateFunction = useNavigate() + const guestbookRepository = useGuestbookRepository() + const { setIsLoading } = useLoading() + const { collection, isLoading } = useCollection(collectionRepository, collectionId) + const { guestbook, isLoadingGuestbook, errorGetGuestbook } = useGetGuestbookById({ + guestbookRepository, + guestbookId + }) + const guestbooksGuideUrl = + 'https://guides.dataverse.org/en/latest/user/dataverse-management.html#dataset-guestbooks' + const navigateToGuestbooks = () => navigate(RouteWithParams.GUESTBOOKS(collectionId)) + const { isEditingGuestbook, errorEditingGuestbook, handleEditGuestbook } = useEditGuestbook({ + guestbookRepository, + onSuccessfulEdit: () => { + toast.success(t('alerts.updated')) + navigateToGuestbooks() + } + }) + const isLoadingData = isLoading || isLoadingGuestbook + + useEffect(() => { + setIsLoading(isLoadingData) + }, [isLoadingData, setIsLoading]) + + const handleSubmit = (guestbookDTO: GuestbookDTO) => { + void handleEditGuestbook(guestbookId, guestbookDTO) + } + + if (!isLoading && !collection) { + return + } + + if (!isLoadingGuestbook && !guestbook && errorGetGuestbook === null) { + return + } + + if (isLoadingData || !collection || (!guestbook && errorGetGuestbook === null)) { + return + } + + return ( +
+ + + + + }} + /> + + + {errorGetGuestbook && {errorGetGuestbook}} + {errorEditingGuestbook && {errorEditingGuestbook}} + + {guestbook && ( + + )} +
+ ) +} diff --git a/src/sections/guestbooks/edit-guestbook/EditGuestbookFactory.tsx b/src/sections/guestbooks/edit-guestbook/EditGuestbookFactory.tsx new file mode 100644 index 000000000..debeb31a6 --- /dev/null +++ b/src/sections/guestbooks/edit-guestbook/EditGuestbookFactory.tsx @@ -0,0 +1,33 @@ +import { ReactElement } from 'react' +import { useParams } from 'react-router-dom' +import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository' +import { NotFoundPage } from '@/sections/not-found-page/NotFoundPage' +import { EditGuestbook } from './EditGuestbook' + +const collectionRepository = new CollectionJSDataverseRepository() + +export class EditGuestbookFactory { + static create(): ReactElement { + return + } +} + +function EditGuestbookWithParams() { + const { collectionId, guestbookId } = useParams<{ + collectionId: string + guestbookId: string + }>() + const parsedGuestbookId = Number(guestbookId) + + if (!collectionId || !Number.isInteger(parsedGuestbookId)) { + return + } + + return ( + + ) +} diff --git a/src/sections/guestbooks/edit-guestbook/useEditGuestbook.ts b/src/sections/guestbooks/edit-guestbook/useEditGuestbook.ts new file mode 100644 index 000000000..5ed1e5b61 --- /dev/null +++ b/src/sections/guestbooks/edit-guestbook/useEditGuestbook.ts @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { useTranslation } from 'react-i18next' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' +import { editGuestbook } from '@/guestbooks/domain/useCases/editGuestbook' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' + +interface UseEditGuestbookProps { + guestbookRepository: GuestbookRepository + onSuccessfulEdit?: () => void +} + +const formatWriteErrorMessage = (error: WriteError): string => { + const errorHandler = new JSDataverseWriteErrorHandler(error) + + return errorHandler.getReasonWithoutStatusCode() ?? errorHandler.getErrorMessage() +} + +const isWriteErrorLike = (error: unknown): error is WriteError => + error instanceof WriteError || + (error instanceof Error && + error.message.includes('There was an error when writing the resource.')) + +export const useEditGuestbook = ({ + guestbookRepository, + onSuccessfulEdit +}: UseEditGuestbookProps) => { + const { t } = useTranslation('guestbooks') + const [isEditingGuestbook, setIsEditingGuestbook] = useState(false) + const [errorEditingGuestbook, setErrorEditingGuestbook] = useState(null) + + const handleEditGuestbook = async (guestbookId: number, guestbook: GuestbookDTO) => { + setIsEditingGuestbook(true) + setErrorEditingGuestbook(null) + + try { + await editGuestbook(guestbookRepository, guestbookId, guestbook) + onSuccessfulEdit?.() + } catch (err: WriteError | unknown) { + if (isWriteErrorLike(err)) { + setErrorEditingGuestbook(formatWriteErrorMessage(err)) + } else if (err instanceof Error && err.message) { + setErrorEditingGuestbook(err.message) + } else { + setErrorEditingGuestbook(t('errors.editGuestbook')) + } + } finally { + setIsEditingGuestbook(false) + } + } + + return { + isEditingGuestbook, + errorEditingGuestbook, + handleEditGuestbook + } +} diff --git a/src/sections/guestbooks/guestbook-form/GuestbookForm.tsx b/src/sections/guestbooks/guestbook-form/GuestbookForm.tsx new file mode 100644 index 000000000..035ccbecd --- /dev/null +++ b/src/sections/guestbooks/guestbook-form/GuestbookForm.tsx @@ -0,0 +1,397 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Col, Form, Row } from '@iqss/dataverse-design-system' +import { DashLg, PlusLg } from 'react-bootstrap-icons' +import { GuestbookQuestionType } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' +import styles from '../create-guestbooks/CreateGuestbook.module.scss' + +interface GuestbookFormProps { + initialGuestbook?: GuestbookDTO + isSubmitting: boolean + submitButtonText: string + cancelButtonText: string + onSubmit: (guestbook: GuestbookDTO) => void + onCancel: () => void +} + +interface CustomQuestionDraft { + draftId: number + id?: number + type: GuestbookQuestionType + questionText: string + required: boolean + hidden: boolean + responseOptions: ResponseOptionDraft[] +} + +interface ResponseOptionDraft { + id?: number + value: string +} + +const createEmptyQuestion = (draftId: number): CustomQuestionDraft => ({ + draftId, + type: 'text', + questionText: '', + required: false, + hidden: false, + responseOptions: [{ value: '' }] +}) + +const getInitialQuestions = (guestbook?: GuestbookDTO): CustomQuestionDraft[] => { + const customQuestions = guestbook?.customQuestions ?? [] + + if (customQuestions.length === 0) { + return [createEmptyQuestion(1)] + } + + return customQuestions.map((question, index) => ({ + draftId: question.id ?? index + 1, + id: question.id, + type: question.type, + questionText: question.question, + required: question.required, + hidden: question.hidden, + responseOptions: + question.optionValues && question.optionValues.length > 0 + ? question.optionValues.map((option) => ({ id: option.id, value: option.value })) + : [{ value: '' }] + })) +} + +export const GuestbookForm = ({ + initialGuestbook, + isSubmitting, + submitButtonText, + cancelButtonText, + onSubmit, + onCancel +}: GuestbookFormProps) => { + const { t } = useTranslation('guestbooks') + const [guestbookName, setGuestbookName] = useState(initialGuestbook?.name ?? '') + const [nameRequired, setNameRequired] = useState(initialGuestbook?.nameRequired ?? false) + const [emailRequired, setEmailRequired] = useState(initialGuestbook?.emailRequired ?? false) + const [institutionRequired, setInstitutionRequired] = useState( + initialGuestbook?.institutionRequired ?? false + ) + const [positionRequired, setPositionRequired] = useState( + initialGuestbook?.positionRequired ?? false + ) + const [customQuestions, setCustomQuestions] = useState( + getInitialQuestions(initialGuestbook) + ) + + useEffect(() => { + setGuestbookName(initialGuestbook?.name ?? '') + setNameRequired(initialGuestbook?.nameRequired ?? false) + setEmailRequired(initialGuestbook?.emailRequired ?? false) + setInstitutionRequired(initialGuestbook?.institutionRequired ?? false) + setPositionRequired(initialGuestbook?.positionRequired ?? false) + setCustomQuestions(getInitialQuestions(initialGuestbook)) + }, [initialGuestbook]) + + const updateQuestion = ( + questionDraftId: number, + updater: (question: CustomQuestionDraft) => CustomQuestionDraft + ) => { + setCustomQuestions((current) => + current.map((question) => + question.draftId === questionDraftId ? updater(question) : question + ) + ) + } + + const addQuestionAfter = (questionDraftId: number) => { + setCustomQuestions((current) => { + const nextId = Math.max(...current.map((question) => question.draftId), 0) + 1 + const newQuestion = createEmptyQuestion(nextId) + const insertionIndex = current.findIndex((question) => question.draftId === questionDraftId) + + if (insertionIndex === -1) { + return [...current, newQuestion] + } + + const nextQuestions = [...current] + nextQuestions.splice(insertionIndex + 1, 0, newQuestion) + return nextQuestions + }) + } + + const removeQuestion = (questionDraftId: number) => { + setCustomQuestions((current) => { + if (current.length === 1) { + return current + } + return current.filter((question) => question.draftId !== questionDraftId) + }) + } + + const addOptionLine = (questionDraftId: number, optionIndex: number) => { + updateQuestion(questionDraftId, (question) => { + const nextOptions = [...question.responseOptions] + nextOptions.splice(optionIndex + 1, 0, { value: '' }) + return { ...question, responseOptions: nextOptions } + }) + } + + const removeOptionLine = (questionDraftId: number, optionIndex: number) => { + updateQuestion(questionDraftId, (question) => { + if (question.responseOptions.length === 1) { + return question + } + const nextOptions = question.responseOptions.filter((_, index) => index !== optionIndex) + return { ...question, responseOptions: nextOptions } + }) + } + + const buildGuestbookDTO = (): GuestbookDTO => ({ + name: guestbookName.trim(), + enabled: initialGuestbook?.enabled ?? true, + nameRequired, + emailRequired, + institutionRequired, + positionRequired, + ...(initialGuestbook?.createTime !== undefined + ? { createTime: initialGuestbook.createTime } + : {}), + customQuestions: customQuestions + .filter((question) => question.questionText.trim().length > 0) + .map((question, index) => ({ + ...(question.id !== undefined ? { id: question.id } : {}), + question: question.questionText.trim(), + required: question.required, + displayOrder: index, + type: question.type, + hidden: question.hidden, + optionValues: + question.type === 'options' + ? question.responseOptions + .filter((option) => option.value.trim().length > 0) + .map((option, optionIndex) => ({ + ...(option.id !== undefined ? { id: option.id } : {}), + value: option.value.trim(), + displayOrder: optionIndex + })) + : undefined + })) + }) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + onSubmit(buildGuestbookDTO()) + } + + return ( +
+ + + {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.draftId, (current) => ({ + ...current, + type: event.target.value as GuestbookQuestionType + })) + }> + + + + + + + + {t('create.fields.customQuestions.questionText')} + + + updateQuestion(question.draftId, (current) => ({ + ...current, + questionText: event.target.value + })) + } + /> + + +
+ + +
+ +
+ + {question.type === 'options' && ( +
+ + +
+ + + + {t('create.fields.customQuestions.responseOptions')} + + + + {question.responseOptions.map((responseOption, optionIndex) => ( + + +
+ + + + updateQuestion(question.draftId, (current) => ({ + ...current, + responseOptions: current.responseOptions.map((option, index) => + index === optionIndex + ? { ...option, value: event.target.value } + : option + ) + })) + } + /> + + +
+ + +
+ + + ))} +
+ )} + + + updateQuestion(question.draftId, (current) => ({ + ...current, + required: !current.required + })) + } + /> + + {questionIndex !== customQuestions.length - 1 && ( +
+ )} +
+ ))} + + + +
+ + +
+ + ) +} diff --git a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx index c6659c2d4..112952f30 100644 --- a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx +++ b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { ReadError } from '@iqss/dataverse-client-javascript' import { Guestbook } from '@/guestbooks/domain/models/Guestbook' import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { getGuestbooksByCollectionId } from '@/guestbooks/domain/useCases/getGuestbooksByCollectionId' import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' interface UseGetGuestbooksByCollectionIdProps { @@ -38,7 +39,8 @@ export const useGetGuestbooksByCollectionId = ({ setErrorGetGuestbooksByCollectionId(null) try { - const fetchedGuestbooks = await guestbookRepository.getGuestbooksByCollectionId( + const fetchedGuestbooks = await getGuestbooksByCollectionId( + guestbookRepository, collectionIdOrAlias, includeStats, includeInherited diff --git a/src/sections/guestbooks/view-responses/GuestbookResponses.tsx b/src/sections/guestbooks/view-responses/GuestbookResponses.tsx new file mode 100644 index 000000000..9048fc1b0 --- /dev/null +++ b/src/sections/guestbooks/view-responses/GuestbookResponses.tsx @@ -0,0 +1,332 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Alert, Button, Table } from '@iqss/dataverse-design-system' +import { CaretDown, CaretUp, ChevronExpand, Download } from 'react-bootstrap-icons' +import { toast } from 'react-toastify' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { GuestbookResponsesPaginationInfo } from '@/guestbooks/domain/models/GuestbookResponsesPaginationInfo' +import { EventType, GuestbookResponse } from '@/guestbooks/domain/models/GuestbookResponse' +import { downloadGuestbookResponsesByGuestbookId } from '@/guestbooks/domain/useCases/downloadGuestbookResponsesByGuestbookId' +import { useCollection } from '@/sections/collection/useCollection' +import { NotFoundPage } from '@/sections/not-found-page/NotFoundPage' +import { RouteWithParams } from '@/sections/Route.enum' +import { PaginationControls } from '@/sections/shared/pagination/PaginationControls' +import { BreadcrumbsGenerator } from '@/sections/shared/hierarchy/BreadcrumbsGenerator' +import { SeparationLine } from '@/sections/shared/layout/SeparationLine/SeparationLine' +import { downloadFile } from '@/sections/shared/citation/citation-download/useDownloadCitation' +import { useGetGuestbookById } from '@/sections/dataset/dataset-guestbook/useGetGuestbookById' +import { useLoading } from '@/shared/contexts/loading/LoadingContext' +import { GuestbookSkeleton } from '../GuestbookSkeleton' +import { useGuestbookRepository } from '../GuestbookRepositoryContext' +import { useGetGuestbookResponsesByGuestbookId } from './useGetGuestbookResponsesByGuestbookId' +import styles from '../Guestbooks.module.scss' + +interface GuestbookResponsesProps { + collectionRepository: CollectionRepository + collectionId: string + guestbookId: number +} + +type SortableColumn = 'dataset' | 'date' | 'type' | 'file' | 'user' + +const formatResponseDate = (date: string): string => new Date(date).toLocaleDateString() + +const getCustomQuestionsText = (response: GuestbookResponse): string => { + if (!response.customQuestions || response.customQuestions.length === 0) { + return '' + } + + return response.customQuestions + .map((customQuestion) => `${customQuestion.question}: ${customQuestion.response}`) + .join('\n') +} + +export const GuestbookResponses = ({ + collectionRepository, + collectionId, + guestbookId +}: GuestbookResponsesProps) => { + const { t } = useTranslation('guestbooks') + const [sortBy, setSortBy] = useState(null) + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') + const [isDownloadingResponses, setIsDownloadingResponses] = useState(false) + const [downloadResponsesError, setDownloadResponsesError] = useState(null) + const [paginationInfo, setPaginationInfo] = useState( + new GuestbookResponsesPaginationInfo() + ) + const { setIsLoading } = useLoading() + const guestbookRepository = useGuestbookRepository() + const { collection, isLoading } = useCollection(collectionRepository, collectionId) + const { guestbook, isLoadingGuestbook, errorGetGuestbook } = useGetGuestbookById({ + guestbookRepository, + guestbookId + }) + const { + guestbookResponses, + totalGuestbookResponseCount, + isLoadingGuestbookResponses, + errorGetGuestbookResponses + } = useGetGuestbookResponsesByGuestbookId({ + guestbookRepository, + guestbookId, + limit: paginationInfo.pageSize, + offset: paginationInfo.offset + }) + const isLoadingData = isLoading || isLoadingGuestbook || isLoadingGuestbookResponses + const getEventTypeLabel = useCallback( + (eventType: EventType): string => + t(`responses.eventTypes.${eventType}`, { defaultValue: eventType }), + [t] + ) + + useEffect(() => { + setIsLoading(isLoadingData) + }, [isLoadingData, setIsLoading]) + + useEffect(() => { + setPaginationInfo((currentPaginationInfo) => + currentPaginationInfo.withTotal(totalGuestbookResponseCount) + ) + }, [totalGuestbookResponseCount]) + + const sortedGuestbookResponses = useMemo(() => { + if (!sortBy) { + return guestbookResponses + } + + const sorted = [...guestbookResponses] + sorted.sort((first, second) => { + if (sortBy === 'dataset') { + return first.dataset.localeCompare(second.dataset, undefined, { sensitivity: 'base' }) + } + if (sortBy === 'date') { + return new Date(first.date).getTime() - new Date(second.date).getTime() + } + if (sortBy === 'type') { + return getEventTypeLabel(first.type).localeCompare(getEventTypeLabel(second.type)) + } + if (sortBy === 'file') { + return (first.fileName ?? '').localeCompare(second.fileName ?? '', undefined, { + sensitivity: 'base' + }) + } + return first.userName.localeCompare(second.userName, undefined, { + sensitivity: 'base' + }) + }) + + return sortDirection === 'asc' ? sorted : sorted.reverse() + }, [getEventTypeLabel, guestbookResponses, sortBy, sortDirection]) + + const handleSort = (column: SortableColumn) => { + if (sortBy === column) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) + return + } + + setSortBy(column) + setSortDirection('asc') + } + + const sortIndicator = (column: SortableColumn) => { + if (sortBy === column) { + return sortDirection === 'asc' ? ( + + ) : ( + + ) + } + return + } + + const sortButtonClass = (column: SortableColumn) => + `${styles['sort-button']}${sortBy === column ? ` ${styles['sort-button-active']}` : ''}` + const sortHeaderClass = (column: SortableColumn) => + sortBy === column ? styles['sort-header-active'] : '' + + const handleDownloadResponses = async () => { + if (!guestbook) { + return + } + + setDownloadResponsesError(null) + setIsDownloadingResponses(true) + + try { + const csvContent = await downloadGuestbookResponsesByGuestbookId( + guestbookRepository, + guestbook.dataverseId, + guestbook.id + ) + downloadFile(csvContent, `${guestbook.name}-responses.csv`, 'text/csv;charset=utf-8') + toast.success(t('alerts.downloadStarted')) + } catch { + setDownloadResponsesError(t('errors.downloadResponses')) + } finally { + setIsDownloadingResponses(false) + } + } + + if (!isLoading && !collection) { + return + } + + if (!isLoadingGuestbook && !guestbook && errorGetGuestbook === null) { + return + } + + if (isLoadingData || !collection || (!guestbook && errorGetGuestbook === null)) { + return + } + + return ( +
+ + +
+
+

{collection.name}

+ {collection.affiliation ? ( + ({collection.affiliation}) + ) : null} +
+
+ + + + + + {errorGetGuestbook && {errorGetGuestbook}} + {errorGetGuestbookResponses && {errorGetGuestbookResponses}} + {downloadResponsesError && {downloadResponsesError}} + + {guestbook && ( + <> +
+
+ {t('responses.guestbookName')} + {guestbook.name} +
+
+ + + +
+ + {t('responses.count', { + count: totalGuestbookResponseCount + })} + + +
+ + + + + + + + + + + + + + {sortedGuestbookResponses.length === 0 ? ( + + + + ) : ( + sortedGuestbookResponses.map((response) => ( + + + + + + + + + )) + )} + +
+ + + + + + + + + + {t('responses.table.customQuestions')}
+ {t('responses.noRecords')} +
{response.dataset}{formatResponseDate(response.date)}{getEventTypeLabel(response.type)}{response.fileName ?? ''}{response.userName} + {getCustomQuestionsText(response)} +
+ {paginationInfo.totalPages > 1 && ( + + )} + + )} +
+ ) +} diff --git a/src/sections/guestbooks/view-responses/GuestbookResponsesFactory.tsx b/src/sections/guestbooks/view-responses/GuestbookResponsesFactory.tsx new file mode 100644 index 000000000..50224d2b0 --- /dev/null +++ b/src/sections/guestbooks/view-responses/GuestbookResponsesFactory.tsx @@ -0,0 +1,33 @@ +import { ReactElement } from 'react' +import { useParams } from 'react-router-dom' +import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository' +import { NotFoundPage } from '@/sections/not-found-page/NotFoundPage' +import { GuestbookResponses } from './GuestbookResponses' + +const collectionRepository = new CollectionJSDataverseRepository() + +export class GuestbookResponsesFactory { + static create(): ReactElement { + return + } +} + +function GuestbookResponsesWithParams() { + const { collectionId, guestbookId } = useParams<{ + collectionId: string + guestbookId: string + }>() + const parsedGuestbookId = Number(guestbookId) + + if (!collectionId || !Number.isInteger(parsedGuestbookId)) { + return + } + + return ( + + ) +} diff --git a/src/sections/guestbooks/view-responses/useGetGuestbookResponsesByGuestbookId.ts b/src/sections/guestbooks/view-responses/useGetGuestbookResponsesByGuestbookId.ts new file mode 100644 index 000000000..ce3908968 --- /dev/null +++ b/src/sections/guestbooks/view-responses/useGetGuestbookResponsesByGuestbookId.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from 'react' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { useTranslation } from 'react-i18next' +import { + GuestbookResponse, + GuestbookResponseSubset +} from '@/guestbooks/domain/models/GuestbookResponse' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { getGuestbookResponsesByGuestbookId } from '@/guestbooks/domain/useCases/getGuestbookResponsesByGuestbookId' +import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' + +interface UseGetGuestbookResponsesByGuestbookIdProps { + guestbookRepository: GuestbookRepository + guestbookId?: number + limit?: number + offset?: number +} + +export const useGetGuestbookResponsesByGuestbookId = ({ + guestbookRepository, + guestbookId, + limit, + offset +}: UseGetGuestbookResponsesByGuestbookIdProps) => { + const { t } = useTranslation('guestbooks') + const [guestbookResponses, setGuestbookResponses] = useState([]) + const [totalGuestbookResponseCount, setTotalGuestbookResponseCount] = useState(0) + const [isLoadingGuestbookResponses, setIsLoadingGuestbookResponses] = useState( + guestbookId !== undefined + ) + const [errorGetGuestbookResponses, setErrorGetGuestbookResponses] = useState(null) + + const fetchGuestbookResponses = useCallback(async () => { + if (guestbookId === undefined) { + setGuestbookResponses([]) + setTotalGuestbookResponseCount(0) + setIsLoadingGuestbookResponses(false) + setErrorGetGuestbookResponses(null) + return + } + + setIsLoadingGuestbookResponses(true) + setErrorGetGuestbookResponses(null) + + try { + const responseSubset: GuestbookResponseSubset = await getGuestbookResponsesByGuestbookId( + guestbookRepository, + guestbookId, + limit, + offset + ) + setGuestbookResponses(responseSubset.guestbookResponses ?? []) + setTotalGuestbookResponseCount(responseSubset.totalGuestbookResponseCount ?? 0) + } catch (err) { + setGuestbookResponses([]) + setTotalGuestbookResponseCount(0) + if (err instanceof ReadError) { + const error = new JSDataverseReadErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + setErrorGetGuestbookResponses(formattedError) + } else { + setErrorGetGuestbookResponses(t('errors.getGuestbookResponses')) + } + } finally { + setIsLoadingGuestbookResponses(false) + } + }, [guestbookId, guestbookRepository, limit, offset, t]) + + useEffect(() => { + void fetchGuestbookResponses() + }, [fetchGuestbookResponses]) + + return { + guestbookResponses, + totalGuestbookResponseCount, + isLoadingGuestbookResponses, + errorGetGuestbookResponses, + fetchGuestbookResponses + } +} diff --git a/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts b/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts index fa39c51a9..7b9dd879b 100644 --- a/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts +++ b/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts @@ -1,10 +1,10 @@ import { - type CreateGuestbookDTO, type GuestbookResponseSubset, type Guestbook as JSDataverseGuestbook } from '@iqss/dataverse-client-javascript' import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' export const storybookGuestbook: Guestbook = { id: 3, @@ -45,10 +45,14 @@ export const storybookClientGuestbooks: JSDataverseGuestbook[] = [ ] export class GuestbookMockRepository implements GuestbookRepository { - createGuestbook(_collectionIdOrAlias: number | string, _guestbook: CreateGuestbookDTO) { + createGuestbook(_collectionIdOrAlias: number | string, _guestbook: GuestbookDTO) { return Promise.resolve(storybookGuestbook.id) } + editGuestbook(_guestbookId: number, _guestbook: GuestbookDTO): Promise { + return Promise.resolve() + } + getGuestbook(_guestbookId: number): Promise { return Promise.resolve(storybookGuestbook) } 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 738f3237d..48a9c2574 100644 --- a/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx +++ b/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx @@ -129,6 +129,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => { guestbookRepository = { createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy .stub() .as('getGuestbook') diff --git a/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx b/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx index e47171a66..55a5c94b7 100644 --- a/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx +++ b/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx @@ -25,6 +25,7 @@ describe('DatasetGuestbook', () => { beforeEach(() => { guestbookRepository = { createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), getGuestbookResponsesByGuestbookId: cy.stub(), diff --git a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx index c41e8f09a..7486692da 100644 --- a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx @@ -105,6 +105,7 @@ describe('EditDatasetTerms', () => { licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses) guestbookRepository = { createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub().resolves(mockGuestbooks), getGuestbookResponsesByGuestbookId: cy.stub(), diff --git a/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx index 99fd10791..68de4f70d 100644 --- a/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx @@ -97,6 +97,7 @@ describe('EditGuestbook', () => { beforeEach(() => { guestbookRepository = { createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub().resolves(mockGuestbooks), getGuestbookResponsesByGuestbookId: cy.stub(), diff --git a/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx index 7925d99e5..4d97eefaa 100644 --- a/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx @@ -11,6 +11,7 @@ describe('useAssignDatasetGuestbook', () => { onSuccessfulAssignDatasetGuestbook = cy.stub().as('onSuccessfulAssignDatasetGuestbook') guestbookRepository = { createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), getGuestbookResponsesByGuestbookId: 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 fddb589af..498f7ca55 100644 --- a/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx @@ -11,6 +11,7 @@ describe('useRemoveDatasetGuestbook', () => { onSuccessfulRemoveDatasetGuestbook = cy.stub().as('onSuccessfulRemoveDatasetGuestbook') guestbookRepository = { createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), getGuestbookResponsesByGuestbookId: 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 ab29b2d42..493fc7fe3 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 @@ -198,6 +198,7 @@ describe('AccessFileMenu', () => { it('opens the guestbook modal before starting the file download', () => { const guestbookRepository: GuestbookRepository = { createGuestbook: cy.stub().resolves(1), + editGuestbook: cy.stub().resolves(), getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook), getGuestbooksByCollectionId: cy.stub().resolves([]), getGuestbookResponsesByGuestbookId: cy.stub(), @@ -334,6 +335,7 @@ describe('AccessFileMenu', () => { it('opens the terms modal when custom terms exist without a guestbook', () => { const guestbookRepository: GuestbookRepository = { createGuestbook: cy.stub().resolves(1), + editGuestbook: cy.stub().resolves(), getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook), getGuestbooksByCollectionId: cy.stub().resolves([]), getGuestbookResponsesByGuestbookId: cy.stub(), diff --git a/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx b/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx index 009507ecb..8589a63d6 100644 --- a/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx +++ b/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx @@ -31,6 +31,9 @@ const GuestbookActionButtonsTestWrapper = ({ isTogglingEnabled = false, isDownloadingResponses = false, onToggleEnabled = () => {}, + onCopy, + onEdit, + onViewResponses, onDownloadResponses = () => {} }: { isEnabled?: boolean @@ -39,6 +42,9 @@ const GuestbookActionButtonsTestWrapper = ({ isTogglingEnabled?: boolean isDownloadingResponses?: boolean onToggleEnabled?: () => void + onCopy?: () => void + onEdit?: () => void + onViewResponses?: () => void onDownloadResponses?: () => void }) => { const [showPreview, setShowPreview] = useState(false) @@ -49,6 +55,9 @@ const GuestbookActionButtonsTestWrapper = ({ isEnabled={isEnabled} onView={() => setShowPreview(true)} onToggleEnabled={onToggleEnabled} + onCopy={onCopy} + onEdit={onEdit} + onViewResponses={onViewResponses} canToggleEnabled={canToggleEnabled} canEdit={canEdit} isTogglingEnabled={isTogglingEnabled} @@ -80,11 +89,33 @@ describe('GuestbookActionButtons', () => { cy.findByRole('button', { name: 'Enable' }).should('exist') }) + it('shows the delete action only when the guestbook is disabled and places it after download responses', () => { + cy.customMount() + + cy.findByRole('button', { name: 'Delete' }).should('not.exist') + + cy.customMount() + + cy.findByRole('button', { name: 'Delete' }).should('exist') + cy.findByRole('group', { name: 'Action' }) + .find('button') + .then(($buttons) => { + const buttonNames = Array.from($buttons).map( + (button) => button.getAttribute('aria-label') ?? button.textContent?.trim() + ) + + expect(buttonNames.indexOf('Delete')).to.be.greaterThan( + buttonNames.indexOf('Download responses') + ) + expect(buttonNames.indexOf('Delete')).to.be.lessThan(buttonNames.indexOf('View Responses')) + }) + }) + 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: 'Preview' }).should('exist') cy.findByRole('button', { name: 'Copy' }).should('exist') cy.findByRole('button', { name: 'Edit' }).should('exist') cy.findByRole('button', { name: 'Download responses' }).should('exist') @@ -94,17 +125,17 @@ describe('GuestbookActionButtons', () => { 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: 'Preview' }).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', () => { + it('opens and closes the preview guestbook modal from the preview button', () => { cy.customMount() - cy.findByRole('button', { name: 'View' }).click() + cy.findByRole('button', { name: 'Preview' }).click() cy.findByRole('dialog').should('be.visible') cy.findByText('Preview Guestbook').should('exist') cy.findByText('Downloadable Guestbook').should('exist') @@ -122,6 +153,33 @@ describe('GuestbookActionButtons', () => { cy.get('@onDownloadResponses').should('have.been.calledOnce') }) + it('triggers edit handler when supplied', () => { + const onEdit = cy.stub().as('onEdit') + + cy.customMount() + + cy.findByRole('button', { name: 'Edit' }).click() + cy.get('@onEdit').should('have.been.calledOnce') + }) + + it('triggers copy handler when supplied', () => { + const onCopy = cy.stub().as('onCopy') + + cy.customMount() + + cy.findByRole('button', { name: 'Copy' }).click() + cy.get('@onCopy').should('have.been.calledOnce') + }) + + it('triggers view responses handler when supplied', () => { + const onViewResponses = cy.stub().as('onViewResponses') + + cy.customMount() + + cy.findByRole('button', { name: 'View Responses' }).click() + cy.get('@onViewResponses').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') @@ -142,22 +200,12 @@ describe('GuestbookActionButtons', () => { cy.get('@onDownloadResponses').should('have.been.calledOnce') }) - it('opens the not implemented modal from copy, edit, and view responses buttons', () => { - cy.customMount() + it('opens the not implemented modal from the disabled guestbook delete button', () => { + cy.customMount() - cy.findByRole('button', { name: 'Copy' }).click() + cy.findByRole('button', { name: 'Delete' }).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', () => { diff --git a/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx b/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx index 45afe5c71..30008dd4f 100644 --- a/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx +++ b/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx @@ -1,5 +1,6 @@ import { ReactNode, Suspense } from 'react' import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router-dom' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' import { Guestbook } from '@/guestbooks/domain/models/Guestbook' @@ -96,6 +97,12 @@ describe('ManageGuestbooks', () => { return <>{children} } + const LocationDisplay = () => { + const location = useLocation() + + return
{location.pathname}
+ } + const defaultGuestbooks = [ guestbook, rootGuestbook, @@ -127,6 +134,7 @@ describe('ManageGuestbooks', () => { guestbookRepository = { createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy .stub() @@ -163,6 +171,7 @@ describe('ManageGuestbooks', () => { + @@ -193,6 +202,24 @@ describe('ManageGuestbooks', () => { cy.findByText('Your download has started.').should('exist') }) + it('navigates to the guestbook responses page from the view responses action', () => { + mountComponent() + + cy.contains('tbody tr', 'Downloadable Guestbook') + .findByRole('button', { name: 'View Responses' }) + .click() + + cy.findByTestId('location-display').should('have.text', '/17/guestbooks/10/responses') + }) + + it('navigates to the create guestbook page from the copy action', () => { + mountComponent() + + cy.contains('tbody tr', 'Downloadable Guestbook').findByRole('button', { name: 'Copy' }).click() + + cy.findByTestId('location-display').should('have.text', '/17/guestbooks/create') + }) + it('sorts guestbooks by name and toggles sort direction on repeated clicks', () => { mountComponent() @@ -465,7 +492,7 @@ describe('ManageGuestbooks', () => { it('opens and closes the preview guestbook modal from the page ui', () => { mountComponent() - cy.findAllByRole('button', { name: 'View' }).first().click() + cy.findAllByRole('button', { name: 'Preview' }).first().click() cy.findByRole('dialog').should('be.visible') cy.findByText('Preview Guestbook').should('exist') cy.findByText('Close').click() @@ -534,14 +561,14 @@ describe('ManageGuestbooks', () => { 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', () => { + it('only shows preview, 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: 'Preview' }).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') diff --git a/tests/component/sections/guestbooks/create-guestbooks/CreateGuestbook.spec.tsx b/tests/component/sections/guestbooks/create-guestbooks/CreateGuestbook.spec.tsx index 79f01a73d..301312e1d 100644 --- a/tests/component/sections/guestbooks/create-guestbooks/CreateGuestbook.spec.tsx +++ b/tests/component/sections/guestbooks/create-guestbooks/CreateGuestbook.spec.tsx @@ -1,19 +1,23 @@ import { act, renderHook } from '@testing-library/react' -import { type CreateGuestbookDTO, WriteError } from '@iqss/dataverse-client-javascript' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { useLocation } from 'react-router-dom' import { CreateGuestbook } from '@/sections/guestbooks/create-guestbooks/CreateGuestbook' +import { CreateGuestbookButton } from '@/sections/guestbooks/create-guestbooks/CreateGuestbookButton' import { useCreateGuestbook } from '@/sections/guestbooks/create-guestbooks/useCreateGuestbook' import { GuestbookRepositoryProvider } from '@/sections/guestbooks/GuestbookRepositoryProvider' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' import { createGuestbookRepositoryStub } from '../createGuestbookRepositoryStub' type CreateGuestbookStub = sinon.SinonStub< - [collectionIdOrAlias: number | string, guestbook: CreateGuestbookDTO], + [collectionIdOrAlias: number | string, guestbook: GuestbookDTO], Promise > -const guestbook: CreateGuestbookDTO = { +const guestbook: GuestbookDTO = { name: 'Test Guestbook', enabled: false, emailRequired: true, @@ -31,6 +35,32 @@ const guestbook: CreateGuestbookDTO = { ] } +const sourceGuestbookToCopy: Guestbook = { + id: 10, + name: 'Source Guestbook', + enabled: false, + emailRequired: true, + nameRequired: true, + institutionRequired: true, + positionRequired: false, + customQuestions: [ + { + id: 21, + question: 'Preferred format', + required: true, + displayOrder: 0, + type: 'options', + hidden: false, + optionValues: [ + { id: 31, value: 'CSV', displayOrder: 0 }, + { id: 32, value: 'JSON', displayOrder: 1 } + ] + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 17 +} + describe('CreateGuestbook', () => { const collectionRepository = {} as CollectionRepository let guestbookRepository: GuestbookRepository @@ -54,8 +84,15 @@ describe('CreateGuestbook', () => { ) + const mountCopyGuestbook = () => + cy.customMount( + + + , + [{ pathname: '/root/guestbooks/create', state: { guestbookToCopy: sourceGuestbookToCopy } }] + ) - const expectGuestbookCreatedWith = (expectedGuestbook: CreateGuestbookDTO) => { + const expectGuestbookCreatedWith = (expectedGuestbook: GuestbookDTO) => { cy.wrap(null).should(() => { expect(createGuestbookStub).to.have.been.calledOnce @@ -77,6 +114,47 @@ describe('CreateGuestbook', () => { }) }) + it('prefills a copied guestbook and submits it as a new guestbook', () => { + mountCopyGuestbook() + + cy.findByDisplayValue('Copy of Source Guestbook').should('exist') + cy.findByLabelText('Name').should('be.checked') + cy.findByLabelText('Email').should('be.checked') + cy.findByLabelText('Institution').should('be.checked') + cy.findByLabelText('Position').should('not.be.checked') + cy.findByDisplayValue('Preferred format').should('exist') + cy.findByDisplayValue('CSV').should('exist') + cy.findByDisplayValue('JSON').should('exist') + cy.findByLabelText('Required field').should('be.checked') + + cy.get('button[type="submit"]').click() + cy.wrap(guestbookRepository.getGuestbook as Cypress.Agent).should( + 'not.have.been.called' + ) + + expectGuestbookCreatedWith({ + name: 'Copy of Source Guestbook', + enabled: false, + nameRequired: true, + emailRequired: true, + institutionRequired: true, + positionRequired: false, + customQuestions: [ + { + question: 'Preferred format', + required: true, + displayOrder: 0, + type: 'options', + hidden: false, + optionValues: [ + { value: 'CSV', displayOrder: 0 }, + { value: 'JSON', displayOrder: 1 } + ] + } + ] + }) + }) + it('submits a guestbook with single line and multiple line custom questions', () => { mountCreateGuestbook() @@ -170,6 +248,49 @@ describe('CreateGuestbook', () => { ] }) }) + + it('renders collection not found page when the collection cannot be fetched', () => { + collectionRepository.getById = cy.stub().rejects(new Error('missing collection')) + + mountCreateGuestbook() + + cy.findByTestId('not-found-page').should('exist') + cy.findByText(/We can't find the/i).should('exist') + cy.findByText('Collection').should('exist') + }) + + it('shows an error alert when creating the guestbook fails', () => { + createGuestbookStub.rejects(new Error('unexpected')) + + mountCreateGuestbook() + + cy.get('#guestbook-name').type('Research Use Guestbook') + cy.get('button[type="submit"]').click() + + cy.findByText(/Something went wrong creating the guestbook/i).should('exist') + }) +}) + +describe('CreateGuestbookButton', () => { + const LocationDisplay = () => { + const location = useLocation() + + return
{location.pathname}
+ } + + it('navigates to the create guestbook page when clicked', () => { + cy.customMount( + <> + + + , + ['/root/guestbooks'] + ) + + cy.findByRole('button', { name: 'Create Dataset Guestbook' }).click() + + cy.findByTestId('location-display').should('have.text', '/root/guestbooks/create') + }) }) describe('useCreateGuestbook', () => { diff --git a/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts b/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts index edbaef67d..8cef378a6 100644 --- a/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts +++ b/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts @@ -2,6 +2,7 @@ import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookR export const createGuestbookRepositoryStub = (): GuestbookRepository => ({ createGuestbook: cy.stub(), + editGuestbook: cy.stub(), getGuestbook: cy.stub(), getGuestbooksByCollectionId: cy.stub(), getGuestbookResponsesByGuestbookId: cy.stub(), diff --git a/tests/component/sections/guestbooks/edit-guestbook/EditGuestbook.spec.tsx b/tests/component/sections/guestbooks/edit-guestbook/EditGuestbook.spec.tsx new file mode 100644 index 000000000..d5a994b3b --- /dev/null +++ b/tests/component/sections/guestbooks/edit-guestbook/EditGuestbook.spec.tsx @@ -0,0 +1,150 @@ +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { GuestbookRepositoryProvider } from '@/sections/guestbooks/GuestbookRepositoryProvider' +import { EditGuestbook } from '@/sections/guestbooks/edit-guestbook/EditGuestbook' +import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' + +const guestbook: Guestbook = { + id: 10, + name: 'Research Guestbook', + enabled: true, + emailRequired: true, + nameRequired: false, + institutionRequired: true, + positionRequired: false, + customQuestions: [ + { + id: 55, + question: 'Preferred format', + required: true, + displayOrder: 0, + type: 'options', + hidden: false, + optionValues: [ + { id: 101, value: 'CSV', displayOrder: 0 }, + { id: 102, value: 'JSON', displayOrder: 1 } + ] + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 17 +} + +describe('EditGuestbook', () => { + const collectionRepository = {} as CollectionRepository + let guestbookRepository: GuestbookRepository + + beforeEach(() => { + collectionRepository.getById = cy.stub().resolves( + CollectionMother.create({ + id: '17', + name: 'SubCollection' + }) + ) + guestbookRepository = { + createGuestbook: cy.stub(), + editGuestbook: cy.stub().as('editGuestbook').resolves(undefined), + getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook), + getGuestbooksByCollectionId: cy.stub(), + getGuestbookResponsesByGuestbookId: cy.stub(), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy.stub(), + assignDatasetGuestbook: cy.stub(), + removeDatasetGuestbook: cy.stub() + } + }) + + const mountComponent = () => + cy.customMount( + + + + ) + + it('prefills fields correctly', () => { + mountComponent() + + cy.findByDisplayValue('Research Guestbook').should('exist') + cy.findByLabelText('Email').should('be.checked') + cy.findByLabelText('Institution').should('be.checked') + cy.findByLabelText('Name').should('not.be.checked') + cy.findByLabelText('Position').should('not.be.checked') + cy.findByDisplayValue('Preferred format').should('exist') + cy.findByDisplayValue('CSV').should('exist') + cy.findByDisplayValue('JSON').should('exist') + cy.findByLabelText('Required field').should('be.checked') + + cy.get('@getGuestbook').should('have.been.calledOnceWith', guestbook.id) + cy.get('@editGuestbook').should('not.have.been.called') + }) + + it('shows success toast after save changes', () => { + mountComponent() + + cy.findByDisplayValue('Research Guestbook').clear().type('Updated Guestbook') + cy.findByRole('button', { name: 'Save Changes' }).click() + + cy.get('@editGuestbook').should('have.been.calledOnce') + cy.findByText('The guestbook has been updated.').should('exist') + }) + + it('renders when the guestbook response omits custom questions', () => { + ;(guestbookRepository.getGuestbook as Cypress.Agent).resolves({ + ...guestbook, + customQuestions: undefined + }) + + mountComponent() + + cy.findByDisplayValue('Research Guestbook').should('exist') + cy.findByRole('button', { name: 'Save Changes' }).should('exist') + cy.findByLabelText('Add question').should('exist') + }) + + it('renders 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('renders not found page when the guestbook cannot be found', () => { + ;(guestbookRepository.getGuestbook as Cypress.Agent).resolves(undefined) + + mountComponent() + + cy.findByTestId('not-found-page').should('exist') + }) + + it('shows an error alert when getting the guestbook fails', () => { + ;(guestbookRepository.getGuestbook as Cypress.Agent).rejects( + new Error('unexpected') + ) + + mountComponent() + + cy.findByText(/Something went wrong getting the guestbook/i).should('exist') + }) + + it('shows an error alert when editing the guestbook fails', () => { + ;(guestbookRepository.editGuestbook as Cypress.Agent).rejects( + new Error('unexpected') + ) + + mountComponent() + + cy.findByDisplayValue('Research Guestbook').clear().type('Updated Guestbook') + cy.findByRole('button', { name: 'Save Changes' }).click() + + cy.findByText(/unexpected/i).should('exist') + }) +}) diff --git a/tests/component/sections/guestbooks/edit-guestbook/useEditGuestbook.spec.tsx b/tests/component/sections/guestbooks/edit-guestbook/useEditGuestbook.spec.tsx new file mode 100644 index 000000000..f4141e354 --- /dev/null +++ b/tests/component/sections/guestbooks/edit-guestbook/useEditGuestbook.spec.tsx @@ -0,0 +1,166 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import i18next, { i18n as I18nInstance } from 'i18next' +import { initReactI18next } from 'react-i18next' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { GuestbookDTO } from '@/guestbooks/domain/useCases/DTOs/GuestbookDTO' +import { useEditGuestbook } from '@/sections/guestbooks/edit-guestbook/useEditGuestbook' + +const guestbookId = 10 +const guestbook: GuestbookDTO = { + name: 'Research Guestbook', + enabled: true, + emailRequired: true, + nameRequired: false, + institutionRequired: true, + positionRequired: false, + createTime: '2026-01-01T00:00:00.000Z', + customQuestions: [] +} + +const createI18n = (): I18nInstance => { + const instance = i18next.createInstance() + void instance.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + ns: ['guestbooks'], + defaultNS: 'guestbooks', + initImmediate: false, + resources: { + en: { + guestbooks: { + errors: { + editGuestbook: 'Default edit guestbook error.' + } + } + } + } + }) + + return instance +} + +const createGuestbookRepositoryStub = (): GuestbookRepository => ({ + createGuestbook: cy.stub(), + editGuestbook: 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() +}) + +describe('useEditGuestbook', () => { + let i18n: I18nInstance + let guestbookRepository: GuestbookRepository + let onSuccessfulEdit: Cypress.Agent + + beforeEach(() => { + i18n = createI18n() + guestbookRepository = createGuestbookRepositoryStub() + onSuccessfulEdit = cy.stub().as('onSuccessfulEdit') + }) + + const renderUseEditGuestbook = () => + renderHook( + () => + useEditGuestbook({ + guestbookRepository, + onSuccessfulEdit + }), + { + wrapper: ({ children }) => {children} + } + ) + + it('initializes with default state', async () => { + const { result } = renderUseEditGuestbook() + + await waitFor(() => { + expect(result.current.isEditingGuestbook).to.equal(false) + expect(result.current.errorEditingGuestbook).to.equal(null) + }) + }) + + it('edits a guestbook and calls success callback', async () => { + const editGuestbookStub = guestbookRepository.editGuestbook as Cypress.Agent + editGuestbookStub.resolves(undefined) + + const { result } = renderUseEditGuestbook() + + await act(async () => { + await result.current.handleEditGuestbook(guestbookId, guestbook) + }) + + expect(editGuestbookStub).to.have.been.calledOnceWith(guestbookId, guestbook) + expect(onSuccessfulEdit).to.have.been.calledOnce + expect(result.current.isEditingGuestbook).to.equal(false) + expect(result.current.errorEditingGuestbook).to.equal(null) + }) + + it('sets formatted error when edit fails with WriteError', async () => { + const writeError = new WriteError('[400] Guestbook name already exists') + const editGuestbookStub = guestbookRepository.editGuestbook as Cypress.Agent + editGuestbookStub.rejects(writeError) + + const { result } = renderUseEditGuestbook() + + await act(async () => { + await result.current.handleEditGuestbook(guestbookId, guestbook) + }) + + expect(onSuccessfulEdit).not.to.have.been.called + expect(result.current.errorEditingGuestbook).to.equal('Guestbook name already exists') + expect(result.current.isEditingGuestbook).to.equal(false) + }) + + it('sets formatted error when edit fails with WriteError-shaped Error', async () => { + const editGuestbookStub = guestbookRepository.editGuestbook as Cypress.Agent + editGuestbookStub.rejects( + new Error( + 'There was an error when writing the resource. Reason was: [409] Guestbook is in use' + ) + ) + + const { result } = renderUseEditGuestbook() + + await act(async () => { + await result.current.handleEditGuestbook(guestbookId, guestbook) + }) + + expect(onSuccessfulEdit).not.to.have.been.called + expect(result.current.errorEditingGuestbook).to.equal('Guestbook is in use') + }) + + it('sets error message when edit fails with a regular Error', async () => { + const editGuestbookStub = guestbookRepository.editGuestbook as Cypress.Agent + editGuestbookStub.rejects(new Error('Network unavailable')) + + const { result } = renderUseEditGuestbook() + + await act(async () => { + await result.current.handleEditGuestbook(guestbookId, guestbook) + }) + + expect(onSuccessfulEdit).not.to.have.been.called + expect(result.current.errorEditingGuestbook).to.equal('Network unavailable') + }) + + it('sets default error when edit fails with an unknown error', async () => { + const editGuestbookStub = guestbookRepository.editGuestbook as Cypress.Agent + editGuestbookStub.rejects('Error') + + const { result } = renderUseEditGuestbook() + + await act(async () => { + await result.current.handleEditGuestbook(guestbookId, guestbook) + }) + + expect(onSuccessfulEdit).not.to.have.been.called + expect(result.current.errorEditingGuestbook).to.equal('Default edit guestbook error.') + }) +}) diff --git a/tests/component/sections/guestbooks/view-responses/GuestbookResponses.spec.tsx b/tests/component/sections/guestbooks/view-responses/GuestbookResponses.spec.tsx new file mode 100644 index 000000000..d53930581 --- /dev/null +++ b/tests/component/sections/guestbooks/view-responses/GuestbookResponses.spec.tsx @@ -0,0 +1,341 @@ +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { EventType, GuestbookResponse } from '@/guestbooks/domain/models/GuestbookResponse' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { GuestbookRepositoryProvider } from '@/sections/guestbooks/GuestbookRepositoryProvider' +import { GuestbookResponses } from '@/sections/guestbooks/view-responses/GuestbookResponses' +import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' + +const guestbook: Guestbook = { + id: 10, + name: 'Research Guestbook', + enabled: true, + emailRequired: true, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 17 +} + +const guestbookResponses: GuestbookResponse[] = [ + { + id: 1, + dataset: 'Zeta Dataset', + datasetPid: 'doi:10.5072/FK2/ZETA', + date: '2026-02-01T00:00:00.000Z', + type: EventType.DOWNLOAD, + fileName: 'zeta.csv', + fileId: 101, + userName: 'Jane User', + customQuestions: [{ question: 'Purpose', response: 'Replication' }] + }, + { + id: 2, + dataset: 'Alpha Dataset', + datasetPid: 'doi:10.5072/FK2/ALPHA', + date: '2026-01-01T00:00:00.000Z', + type: EventType.ACCESS_REQUEST, + fileName: 'alpha.tab', + fileId: 102, + userName: 'Ada User' + } +] + +const createGuestbookResponse = (id: number): GuestbookResponse => ({ + id, + dataset: `Dataset ${id.toString().padStart(2, '0')}`, + datasetPid: `doi:10.5072/FK2/${id}`, + date: `2026-01-${id.toString().padStart(2, '0')}T00:00:00.000Z`, + type: EventType.DOWNLOAD, + fileName: `file-${id}.csv`, + fileId: id, + userName: `User ${id}`, + customQuestions: [{ question: `Question ${id}`, response: `Answer ${id}` }] +}) + +describe('GuestbookResponses', () => { + const collectionRepository = {} as CollectionRepository + let guestbookRepository: GuestbookRepository + + beforeEach(() => { + collectionRepository.getById = cy.stub().resolves( + CollectionMother.create({ + id: '17', + name: 'SubCollection' + }) + ) + guestbookRepository = { + createGuestbook: cy.stub(), + editGuestbook: cy.stub(), + getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook), + getGuestbooksByCollectionId: cy.stub(), + getGuestbookResponsesByGuestbookId: cy + .stub() + .as('getGuestbookResponsesByGuestbookId') + .resolves({ + guestbookResponses, + totalGuestbookResponseCount: guestbookResponses.length + }), + setGuestbookEnabled: cy.stub(), + downloadGuestbookResponsesByCollectionId: cy.stub(), + downloadGuestbookResponsesByGuestbookId: cy + .stub() + .as('downloadGuestbookResponsesByGuestbookId') + .resolves('dataset,user\nResearch Guestbook,Jane User'), + assignDatasetGuestbook: cy.stub(), + removeDatasetGuestbook: cy.stub() + } + + cy.window().then((win) => { + cy.stub(win.URL, 'createObjectURL').returns('blob:guestbook-responses') + cy.stub(win.URL, 'revokeObjectURL') + }) + }) + + const mountComponent = () => + cy.customMount( + + + + ) + + it('renders guestbook response page details and rows', () => { + mountComponent() + + cy.findAllByText('Guestbook Responses').should('have.length.at.least', 1) + cy.findByRole('heading', { name: 'SubCollection' }).should('exist') + cy.findByText(/Click "Download Responses" to download all collected responses/i).should('exist') + cy.findByText('Guestbook Name').should('exist') + cy.findByText('Research Guestbook').should('exist') + cy.findByText('2 Responses').should('exist') + cy.findByRole('button', { name: 'Download Responses' }).should('exist') + + cy.findByRole('columnheader', { name: /Dataset/ }).should('exist') + cy.findByRole('columnheader', { name: /Date/ }).should('exist') + cy.findByRole('columnheader', { name: /Type/ }).should('exist') + cy.findByRole('columnheader', { name: /File/ }).should('exist') + cy.findByRole('columnheader', { name: /User/ }).should('exist') + cy.findByRole('columnheader', { name: /Custom Questions/ }).should('exist') + cy.findByText('Zeta Dataset').should('exist') + cy.findByText('Download').should('exist') + cy.findByText('zeta.csv').should('exist') + cy.findByText('Jane User').should('exist') + cy.findByText(/Purpose: Replication/).should('exist') + + cy.get('@getGuestbook').should('have.been.calledOnceWith', guestbook.id) + cy.get('@getGuestbookResponsesByGuestbookId').should( + 'have.been.calledOnceWith', + guestbook.id, + 10, + 0 + ) + }) + + it('sorts responses by dataset', () => { + mountComponent() + + cy.get('tbody tr td:first-child').then(($cells) => { + expect([...$cells].map((cell) => cell.textContent?.trim())).to.deep.equal([ + 'Zeta Dataset', + 'Alpha Dataset' + ]) + }) + + cy.findByRole('button', { name: /^Dataset/ }).click() + + cy.get('tbody tr td:first-child').then(($cells) => { + expect([...$cells].map((cell) => cell.textContent?.trim())).to.deep.equal([ + 'Alpha Dataset', + 'Zeta Dataset' + ]) + }) + }) + + it('sorts responses by date, type, file, and user and toggles sort direction', () => { + mountComponent() + + cy.findByRole('button', { name: /^Date/ }).click() + cy.get('tbody tr td:first-child').then(($cells) => { + expect([...$cells].map((cell) => cell.textContent?.trim())).to.deep.equal([ + 'Alpha Dataset', + 'Zeta Dataset' + ]) + }) + + cy.findByRole('button', { name: /^Date/ }).click() + cy.get('tbody tr td:first-child').then(($cells) => { + expect([...$cells].map((cell) => cell.textContent?.trim())).to.deep.equal([ + 'Zeta Dataset', + 'Alpha Dataset' + ]) + }) + + cy.findByRole('button', { name: /^Type/ }).click() + cy.get('tbody tr td:first-child').then(($cells) => { + expect([...$cells].map((cell) => cell.textContent?.trim())).to.deep.equal([ + 'Alpha Dataset', + 'Zeta Dataset' + ]) + }) + + cy.findByRole('button', { name: /^File/ }).click() + cy.get('tbody tr td:first-child').then(($cells) => { + expect([...$cells].map((cell) => cell.textContent?.trim())).to.deep.equal([ + 'Alpha Dataset', + 'Zeta Dataset' + ]) + }) + + cy.findByRole('button', { name: /^User/ }).click() + cy.get('tbody tr td:first-child').then(($cells) => { + expect([...$cells].map((cell) => cell.textContent?.trim())).to.deep.equal([ + 'Alpha Dataset', + 'Zeta Dataset' + ]) + }) + }) + + it('renders missing file names as empty and falls back to unknown event type label', () => { + ;( + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + ).resolves({ + guestbookResponses: [ + { + id: 3, + dataset: 'Unknown Event Dataset', + datasetPid: 'doi:10.5072/FK2/UNKNOWN', + date: '2026-03-01T00:00:00.000Z', + type: 'UnknownEvent' as EventType, + userName: 'Unknown User' + } + ], + totalGuestbookResponseCount: 1 + }) + + mountComponent() + + cy.contains('tbody tr', 'Unknown Event Dataset').within(() => { + cy.findByText('UnknownEvent').should('exist') + cy.get('td').eq(3).should('have.text', '') + }) + }) + + it('downloads guestbook responses from the page action', () => { + mountComponent() + + cy.findByRole('button', { name: 'Download Responses' }).click() + + cy.get('@downloadGuestbookResponsesByGuestbookId').should( + 'have.been.calledOnceWith', + guestbook.dataverseId, + guestbook.id + ) + cy.findByText('Your download has started.').should('exist') + }) + + it('shows an error when downloading guestbook responses fails', () => { + ;( + guestbookRepository.downloadGuestbookResponsesByGuestbookId as Cypress.Agent + ).rejects(new Error('download failed')) + + mountComponent() + + cy.findByRole('button', { name: 'Download Responses' }).click() + + cy.findByText(/Something went wrong downloading guestbook responses/i).should('exist') + }) + + it('shows no records found when the guestbook has no responses', () => { + ;( + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + ).resolves({ + guestbookResponses: [], + totalGuestbookResponseCount: 0 + }) + + mountComponent() + + cy.findByText('0 Responses').should('exist') + cy.findByText('No records found.').should('exist') + }) + + it('renders 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('renders not found page when the guestbook cannot be found', () => { + ;(guestbookRepository.getGuestbook as Cypress.Agent).resolves(undefined) + + mountComponent() + + cy.findByTestId('not-found-page').should('exist') + }) + + it('shows an error alert when getting the guestbook fails', () => { + ;(guestbookRepository.getGuestbook as Cypress.Agent).rejects( + new ReadError('Guestbook lookup failed') + ) + + mountComponent() + + cy.findByText(/Guestbook lookup failed/i).should('exist') + }) + + it('shows an error alert when getting guestbook responses fails', () => { + ;( + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + ).rejects(new Error('unexpected')) + + mountComponent() + + cy.findByText(/Something went wrong getting guestbook responses/i).should('exist') + }) + + it('paginates guestbook responses', () => { + const paginatedGuestbookResponses = Array.from({ length: 12 }, (_, index) => + createGuestbookResponse(index + 1) + ) + ;( + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + ).callsFake((_guestbookId: number, limit?: number, offset?: number) => { + const pageSize = limit ?? 10 + const pageOffset = offset ?? 0 + + return Promise.resolve({ + guestbookResponses: paginatedGuestbookResponses.slice(pageOffset, pageOffset + pageSize), + totalGuestbookResponseCount: paginatedGuestbookResponses.length + }) + }) + + mountComponent() + + cy.findByText('Dataset 01').should('exist') + cy.findByText('Dataset 11').should('not.exist') + cy.findByTestId('pagination-controls').should('exist') + + cy.findByRole('button', { name: '2' }).click() + + cy.findByText('Dataset 11').should('exist') + cy.findByText(/Question 11: Answer 11/).should('exist') + cy.findByText('Dataset 01').should('not.exist') + cy.get('@getGuestbookResponsesByGuestbookId').should( + 'have.been.calledWith', + guestbook.id, + 10, + 10 + ) + }) +}) diff --git a/tests/component/sections/guestbooks/view-responses/useGetGuestbookResponsesByGuestbookId.spec.tsx b/tests/component/sections/guestbooks/view-responses/useGetGuestbookResponsesByGuestbookId.spec.tsx new file mode 100644 index 000000000..ca0079c35 --- /dev/null +++ b/tests/component/sections/guestbooks/view-responses/useGetGuestbookResponsesByGuestbookId.spec.tsx @@ -0,0 +1,170 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { EventType, GuestbookResponse } from '@/guestbooks/domain/models/GuestbookResponse' +import { GuestbookResponseSubset } from '@/guestbooks/domain/models/GuestbookResponse' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { useGetGuestbookResponsesByGuestbookId } from '@/sections/guestbooks/view-responses/useGetGuestbookResponsesByGuestbookId' +import { createGuestbookRepositoryStub } from '../createGuestbookRepositoryStub' + +const guestbookResponse: GuestbookResponse = { + id: 1, + dataset: 'Response Dataset', + datasetPid: 'doi:10.5072/FK2/RESP', + date: '2026-01-01T00:00:00.000Z', + type: EventType.DOWNLOAD, + email: 'user@example.com', + fileId: 101, + fileName: 'response.csv', + userName: 'Guest', + customQuestions: [{ question: 'Purpose', response: 'Research' }] +} + +describe('useGetGuestbookResponsesByGuestbookId', () => { + let guestbookRepository: GuestbookRepository + + beforeEach(() => { + guestbookRepository = createGuestbookRepositoryStub() + }) + + it('returns guestbook responses when request succeeds', async () => { + const getGuestbookResponsesByGuestbookIdStub = + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + getGuestbookResponsesByGuestbookIdStub.resolves({ + guestbookResponses: [guestbookResponse], + totalGuestbookResponseCount: 1 + }) + + const { result } = renderHook(() => + useGetGuestbookResponsesByGuestbookId({ + guestbookRepository, + guestbookId: 10, + limit: 10, + offset: 0 + }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbookResponses).to.deep.equal(false) + expect(result.current.errorGetGuestbookResponses).to.deep.equal(null) + expect(result.current.guestbookResponses).to.deep.equal([guestbookResponse]) + expect(result.current.totalGuestbookResponseCount).to.deep.equal(1) + }) + + cy.wrap(getGuestbookResponsesByGuestbookIdStub).should('have.been.calledOnceWith', 10, 10, 0) + }) + + it('does not fetch when guestbook id is undefined', async () => { + const getGuestbookResponsesByGuestbookIdStub = + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + getGuestbookResponsesByGuestbookIdStub.resolves({ + guestbookResponses: [guestbookResponse], + totalGuestbookResponseCount: 1 + }) + + const { result } = renderHook(() => + useGetGuestbookResponsesByGuestbookId({ + guestbookRepository, + guestbookId: undefined + }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbookResponses).to.deep.equal(false) + expect(result.current.errorGetGuestbookResponses).to.deep.equal(null) + expect(result.current.guestbookResponses).to.deep.equal([]) + expect(result.current.totalGuestbookResponseCount).to.deep.equal(0) + }) + + cy.wrap(getGuestbookResponsesByGuestbookIdStub).should('not.have.been.called') + }) + + it('uses empty defaults when response fields are missing', async () => { + const getGuestbookResponsesByGuestbookIdStub = + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + getGuestbookResponsesByGuestbookIdStub.resolves({} as GuestbookResponseSubset) + + const { result } = renderHook(() => + useGetGuestbookResponsesByGuestbookId({ + guestbookRepository, + guestbookId: 10, + limit: 5, + offset: 15 + }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbookResponses).to.deep.equal(false) + expect(result.current.errorGetGuestbookResponses).to.deep.equal(null) + expect(result.current.guestbookResponses).to.deep.equal([]) + expect(result.current.totalGuestbookResponseCount).to.deep.equal(0) + }) + + cy.wrap(getGuestbookResponsesByGuestbookIdStub).should('have.been.calledOnceWith', 10, 5, 15) + }) + + it('resets responses and sets formatted message when request fails with ReadError', async () => { + const getGuestbookResponsesByGuestbookIdStub = + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + getGuestbookResponsesByGuestbookIdStub.onFirstCall().resolves({ + guestbookResponses: [guestbookResponse], + totalGuestbookResponseCount: 1 + }) + getGuestbookResponsesByGuestbookIdStub + .onSecondCall() + .rejects(new ReadError('ReadError message')) + + const { result } = renderHook(() => + useGetGuestbookResponsesByGuestbookId({ + guestbookRepository, + guestbookId: 10 + }) + ) + + await waitFor(() => { + expect(result.current.guestbookResponses).to.deep.equal([guestbookResponse]) + expect(result.current.totalGuestbookResponseCount).to.deep.equal(1) + }) + + await act(async () => { + await result.current.fetchGuestbookResponses() + }) + + expect(result.current.isLoadingGuestbookResponses).to.deep.equal(false) + expect(result.current.guestbookResponses).to.deep.equal([]) + expect(result.current.totalGuestbookResponseCount).to.deep.equal(0) + expect(result.current.errorGetGuestbookResponses).to.deep.equal('ReadError message') + }) + + it('resets responses and sets default message when request fails with non-ReadError', async () => { + const getGuestbookResponsesByGuestbookIdStub = + guestbookRepository.getGuestbookResponsesByGuestbookId as Cypress.Agent + getGuestbookResponsesByGuestbookIdStub.onFirstCall().resolves({ + guestbookResponses: [guestbookResponse], + totalGuestbookResponseCount: 1 + }) + getGuestbookResponsesByGuestbookIdStub.onSecondCall().rejects(new Error('unexpected')) + + const { result } = renderHook(() => + useGetGuestbookResponsesByGuestbookId({ + guestbookRepository, + guestbookId: 10 + }) + ) + + await waitFor(() => { + expect(result.current.guestbookResponses).to.deep.equal([guestbookResponse]) + expect(result.current.totalGuestbookResponseCount).to.deep.equal(1) + }) + + await act(async () => { + await result.current.fetchGuestbookResponses() + }) + + expect(result.current.isLoadingGuestbookResponses).to.deep.equal(false) + expect(result.current.guestbookResponses).to.deep.equal([]) + expect(result.current.totalGuestbookResponseCount).to.deep.equal(0) + expect(result.current.errorGetGuestbookResponses).to.deep.equal( + 'Something went wrong getting guestbook responses. Try again later.' + ) + }) +}) diff --git a/tests/component/sections/templates/DatasetTemplates.spec.tsx b/tests/component/sections/templates/DatasetTemplates.spec.tsx index 47b9befbd..2e8248d82 100644 --- a/tests/component/sections/templates/DatasetTemplates.spec.tsx +++ b/tests/component/sections/templates/DatasetTemplates.spec.tsx @@ -579,7 +579,7 @@ describe('Dataset Templates', () => { mountDatasetTemplates() - cy.findByRole('button', { name: 'View' }).click({ force: true }) + cy.findByRole('button', { name: 'Preview' }).click({ force: true }) cy.findByRole('dialog').within(() => { cy.findByText('Dataset Template Preview').should('exist') cy.findByText('No citation metadata is available for this template.').should('exist') @@ -603,7 +603,7 @@ describe('Dataset Templates', () => { mountDatasetTemplates() - cy.findByRole('button', { name: 'View' }).click({ force: true }) + cy.findByRole('button', { name: 'Preview' }).click({ force: true }) cy.findByRole('dialog').within(() => { cy.findByText('Test Title').should('exist') cy.findByText('No citation metadata is available for this template.').should('not.exist') @@ -635,7 +635,7 @@ describe('Dataset Templates', () => { mountDatasetTemplates() - cy.findByRole('button', { name: 'View' }).click({ force: true }) + cy.findByRole('button', { name: 'Preview' }).click({ force: true }) cy.findByRole('dialog').within(() => { cy.findByText('Title') .closest('.row') @@ -662,7 +662,7 @@ describe('Dataset Templates', () => { mountDatasetTemplates() - cy.findByRole('button', { name: 'View' }).click() + cy.findByRole('button', { name: 'Preview' }).click() cy.findByText('Close').click() cy.findByRole('dialog').should('not.exist') }) @@ -684,7 +684,7 @@ describe('Dataset Templates', () => { mountDatasetTemplates() - cy.findByRole('button', { name: 'View' }).click({ force: true }) + cy.findByRole('button', { name: 'Preview' }).click({ force: true }) cy.findByRole('dialog').within(() => { cy.findByTestId('preview-modal-skeleton').should('exist') }) @@ -699,7 +699,7 @@ describe('Dataset Templates', () => { mountDatasetTemplates() - cy.findByRole('button', { name: 'View' }).click({ force: true }) + cy.findByRole('button', { name: 'Preview' }).click({ force: true }) cy.findByRole('dialog').within(() => { cy.findByText(/Something went wrong getting the template. Try again later./i).should( 'exist' diff --git a/tests/e2e-integration/e2e/sections/guestbooks/ManageGuestbooks.spec.ts b/tests/e2e-integration/e2e/sections/guestbooks/ManageGuestbooks.spec.ts index a00d03fd8..2befe6409 100644 --- a/tests/e2e-integration/e2e/sections/guestbooks/ManageGuestbooks.spec.ts +++ b/tests/e2e-integration/e2e/sections/guestbooks/ManageGuestbooks.spec.ts @@ -36,7 +36,7 @@ describe('Manage Guestbooks', () => { cy.contains('tbody tr', guestbookName).within(() => { cy.findByRole('button', { name: 'Disable' }).should('exist') - cy.findByRole('button', { name: 'View' }).click() + cy.findByRole('button', { name: 'Preview' }).click() }) cy.findByRole('dialog').within(() => { @@ -66,25 +66,6 @@ describe('Manage Guestbooks', () => { 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()}` @@ -103,4 +84,38 @@ describe('Manage Guestbooks', () => { cy.findByText('Your download has started.').should('exist') }) }) + + it('edits a guestbook from the table edit action', () => { + const guestbookName = `E2E Edit Guestbook ${Date.now()}` + const updatedGuestbookName = `${guestbookName} Updated` + + cy.wrap(GuestbookHelper.createAndGetByName(guestbookName), { timeout: 10000 }).then( + (guestbook) => { + cy.visit(GUESTBOOKS_PAGE_URL) + + cy.contains('tbody tr', guestbookName).findByRole('button', { name: 'Edit' }).click() + + cy.url().should('include', `${FRONTEND_BASE_PATH}/root/guestbooks/${guestbook.id}/edit`) + cy.findByRole('link', { name: 'Root' }) + .closest('.breadcrumb') + .within(() => { + cy.findByText('Dataset Guestbooks').should('exist') + cy.findByText('Edit Dataset Guestbook').should('exist') + }) + cy.findByLabelText(/^Guestbook Name/).should('have.value', guestbookName) + + cy.findByLabelText(/^Guestbook Name/) + .clear() + .type(updatedGuestbookName) + cy.findByLabelText('Institution').check() + cy.findByRole('button', { name: 'Save Changes' }).click() + + cy.findByText('The guestbook has been updated.').should('exist') + cy.url().should('include', GUESTBOOKS_PAGE_URL) + cy.contains('tbody tr', updatedGuestbookName).within(() => { + cy.findByRole('button', { name: 'Edit' }).should('exist') + }) + } + ) + }) }) diff --git a/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx b/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx index 694ef000f..17935d506 100644 --- a/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx +++ b/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx @@ -68,7 +68,7 @@ describe('Dataset Templates', () => { cy.findByText(templateName) .closest('tr') .within(() => { - cy.findByRole('button', { name: 'View' }).click({ force: true }) + cy.findByRole('button', { name: 'Preview' }).click({ force: true }) }) cy.findByText(/Dataset Template Preview/i).should('exist')