- {guestbooks.map((guestbook) => (
+ {enabledGuestbooks.map((guestbook) => (
(
+
+
+
+)
diff --git a/src/sections/guestbooks/Guestbooks.module.scss b/src/sections/guestbooks/Guestbooks.module.scss
new file mode 100644
index 000000000..4ed8768df
--- /dev/null
+++ b/src/sections/guestbooks/Guestbooks.module.scss
@@ -0,0 +1,163 @@
+@import 'node_modules/bootstrap/scss/functions';
+@import 'node_modules/bootstrap/scss/variables';
+@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module';
+
+.header {
+ margin-bottom: $spacer;
+}
+
+.header-title {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5rem;
+}
+
+.subtext {
+ color: $dv-subtext-color;
+}
+
+.table-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: $spacer 0;
+}
+
+.table-actions-left {
+ flex: 1;
+}
+
+.table-actions-right {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.include-templates-filter {
+ margin-bottom: 0;
+}
+
+.download-all-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ white-space: nowrap;
+}
+
+@media (max-width: 576px) {
+ .table-actions {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.75rem;
+ }
+
+ .table-actions-left {
+ width: 100%;
+ }
+
+ .table-actions-right {
+ width: 100%;
+ justify-content: stretch;
+ }
+
+ .create-button {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .download-all-button {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+.create-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.action-group {
+ flex-wrap: wrap;
+}
+
+.action-group .btn {
+ min-height: 2.25rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+td {
+ text-align: center;
+ vertical-align: middle;
+}
+
+.template-origin {
+ display: block;
+ color: $dv-subtext-color;
+ font-style: italic;
+ white-space: nowrap;
+ overflow: hidden;
+ word-break: break-word;
+}
+
+.toggle-status-button {
+ margin-right: 0.75rem;
+}
+
+.name-column {
+ width: 32%;
+}
+
+.action-column {
+ width: 32%;
+}
+
+.sort-button {
+ padding: 0;
+ text-decoration: none;
+ color: inherit;
+ background: none;
+ border: none;
+ display: inline-flex;
+ align-items: center;
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+.sort-button:focus,
+.sort-button:active,
+.sort-button:focus-visible {
+ color: inherit;
+ box-shadow: none;
+ opacity: 1;
+}
+
+.sort-button:hover {
+ color: inherit;
+}
+
+.sort-button-active {
+ color: $dv-subtext-color;
+}
+
+.sort-header-active {
+ background-color: rgba($dv-subtext-color, 0.08);
+ color: $dv-subtext-color;
+}
+
+.sort-icon {
+ display: inline-flex;
+ vertical-align: middle;
+}
+
+.empty-state {
+ color: $dv-subtext-color;
+ padding: 2rem;
+ border: 1px solid rgba($dv-subtext-color, 0.2);
+ border-radius: 0.5rem;
+}
diff --git a/src/sections/guestbooks/GuestbooksEmptyState.tsx b/src/sections/guestbooks/GuestbooksEmptyState.tsx
new file mode 100644
index 000000000..4494bc17e
--- /dev/null
+++ b/src/sections/guestbooks/GuestbooksEmptyState.tsx
@@ -0,0 +1,50 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { RouteWithParams } from '@/sections/Route.enum'
+import styles from './Guestbooks.module.scss'
+
+interface GuestbooksEmptyStateProps {
+ collectionId: string
+}
+
+export const GuestbooksEmptyState = ({ collectionId }: GuestbooksEmptyStateProps) => {
+ const { t } = useTranslation('guestbooks')
+ const emptyStateWhyBullets = t('emptyState.whyBullets', {
+ returnObjects: true
+ }) as string[]
+ const emptyStateHowBullets = t('emptyState.howBullets', {
+ returnObjects: true
+ }) as string[]
+
+ const generalInfoUrl = `/spa${RouteWithParams.EDIT_COLLECTION(collectionId)}`
+ const guestbooksGuideUrl =
+ 'https://guides.dataverse.org/en/6.9/user/dataverse-management.html#dataset-guestbooks'
+
+ return (
+
+
+
{t('emptyState.whyTitle')}
+
+ {emptyStateWhyBullets.map((item) => (
+ - {item}
+ ))}
+
+
{t('emptyState.howTitle')}
+
+ {emptyStateHowBullets.map((item) => (
+ - {item}
+ ))}
+
+
+ ,
+ anchorGuide:
+ }}
+ />
+
+
+
+ )
+}
diff --git a/src/sections/guestbooks/GuestbooksFactory.tsx b/src/sections/guestbooks/GuestbooksFactory.tsx
new file mode 100644
index 000000000..9a3ba21fa
--- /dev/null
+++ b/src/sections/guestbooks/GuestbooksFactory.tsx
@@ -0,0 +1,20 @@
+import { ReactElement } from 'react'
+import { useParams } from 'react-router-dom'
+import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository'
+import { Guestbooks } from './ManageGuestbooks'
+
+const collectionRepository = new CollectionJSDataverseRepository()
+
+export class GuestbooksFactory {
+ static create(): ReactElement {
+ return
+ }
+}
+
+function GuestbooksWithParams() {
+ const { collectionId } = useParams<{ collectionId: string }>()
+
+ return (
+
+ )
+}
diff --git a/src/sections/guestbooks/ManageGuestbooks.tsx b/src/sections/guestbooks/ManageGuestbooks.tsx
new file mode 100644
index 000000000..e5d87f5b6
--- /dev/null
+++ b/src/sections/guestbooks/ManageGuestbooks.tsx
@@ -0,0 +1,329 @@
+import { useEffect, useMemo, useState } from 'react'
+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 { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository'
+import { Guestbook } from '@/guestbooks/domain/models/Guestbook'
+import { downloadGuestbookResponsesByDataverseId } from '@/guestbooks/domain/useCases/downloadGuestbookResponsesByDataverseId'
+import { downloadGuestbookResponsesOfAGuestbook } from '@/guestbooks/domain/useCases/downloadGuestbookResponsesOfAGuestbook'
+import { setGuestbookEnabled } from '@/guestbooks/domain/useCases/setGuestbookEnabled'
+import { useCollection } from '@/sections/collection/useCollection'
+import { NotFoundPage } from '@/sections/not-found-page/NotFoundPage'
+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 { GuestbookActionButtons } from './action-buttons/GuestbookActionButtons'
+import { CreateGuestbookButton } from './create-guestbooks/CreateGuestbookButton'
+import { GuestbookSkeleton } from './GuestbookSkeleton'
+import { GuestbooksEmptyState } from './GuestbooksEmptyState'
+import { useGuestbookRepository } from './GuestbookRepositoryContext'
+import { PreviewGuestbookModal } from './preview-modal/PreviewGuestbookModal'
+import { useGetGuestbooksByCollectionId } from './useGetGuestbooksByCollectionId'
+import styles from './Guestbooks.module.scss'
+
+interface GuestbooksProps {
+ collectionRepository: CollectionRepository
+ collectionId: string
+}
+
+export const Guestbooks = ({ collectionRepository, collectionId }: GuestbooksProps) => {
+ const { t } = useTranslation('guestbooks')
+ const [includeGuestbooksFromRoot, setIncludeGuestbooksFromRoot] = useState(true)
+ const [sortBy, setSortBy] = useState<'name' | 'created' | 'usage' | 'responses' | null>(null)
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
+ const [guestbookToPreview, setGuestbookToPreview] = useState
()
+ const [displayGuestbooks, setDisplayGuestbooks] = useState([])
+ const [togglingGuestbookId, setTogglingGuestbookId] = useState()
+ const [toggleGuestbookError, setToggleGuestbookError] = useState(null)
+ const [isDownloadingAllResponses, setIsDownloadingAllResponses] = useState(false)
+ const [downloadingGuestbookId, setDownloadingGuestbookId] = useState()
+ const [downloadResponsesError, setDownloadResponsesError] = useState(null)
+ const guestbookRepository = useGuestbookRepository()
+
+ const { collection, isLoading } = useCollection(collectionRepository, collectionId)
+ const { guestbooks, isLoadingGuestbooksByCollectionId, errorGetGuestbooksByCollectionId } =
+ useGetGuestbooksByCollectionId({
+ guestbookRepository,
+ collectionIdOrAlias: collection?.id,
+ includeStats: true
+ })
+ const rootCollectionNames = collection?.hierarchy?.toArray().map((node) => node.name) ?? []
+
+ const currentDataverseId = Number(collection?.id) || 1
+
+ useEffect(() => {
+ setDisplayGuestbooks(guestbooks)
+ }, [guestbooks])
+
+ const filteredGuestbooks = useMemo(
+ () =>
+ includeGuestbooksFromRoot
+ ? displayGuestbooks
+ : displayGuestbooks.filter((guestbook) => guestbook.dataverseId === currentDataverseId),
+ [displayGuestbooks, includeGuestbooksFromRoot, currentDataverseId]
+ )
+ const sortedGuestbooks = useMemo(() => {
+ if (!sortBy) {
+ return filteredGuestbooks
+ }
+
+ const sorted = [...filteredGuestbooks]
+ sorted.sort((first, second) => {
+ if (sortBy === 'name') {
+ return first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })
+ }
+ if (sortBy === 'created') {
+ return new Date(first.createTime).getTime() - new Date(second.createTime).getTime()
+ }
+ if (sortBy === 'usage') {
+ return (first.usageCount ?? 0) - (second.usageCount ?? 0)
+ }
+ return (first.responseCount ?? 0) - (second.responseCount ?? 0)
+ })
+
+ return sortDirection === 'asc' ? sorted : sorted.reverse()
+ }, [filteredGuestbooks, sortBy, sortDirection])
+
+ const handleSort = (column: 'name' | 'created' | 'usage' | 'responses') => {
+ if (sortBy === column) {
+ setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
+ return
+ }
+
+ setSortBy(column)
+ setSortDirection('asc')
+ }
+
+ const sortIndicator = (column: 'name' | 'created' | 'usage' | 'responses') => {
+ if (sortBy === column) {
+ return sortDirection === 'asc' ? (
+
+ ) : (
+
+ )
+ }
+ return
+ }
+ const sortButtonClass = (column: 'name' | 'created' | 'usage' | 'responses') =>
+ `${styles['sort-button']}${sortBy === column ? ` ${styles['sort-button-active']}` : ''}`
+ const sortHeaderClass = (column: 'name' | 'created' | 'usage' | 'responses') =>
+ sortBy === column ? styles['sort-header-active'] : ''
+
+ const handleToggleEnabled = async (guestbook: Guestbook) => {
+ setToggleGuestbookError(null)
+ setTogglingGuestbookId(guestbook.id)
+
+ try {
+ await setGuestbookEnabled(
+ guestbookRepository,
+ guestbook.dataverseId,
+ guestbook.id,
+ !guestbook.enabled
+ )
+ setDisplayGuestbooks((currentGuestbooks) =>
+ currentGuestbooks.map((currentGuestbook) =>
+ currentGuestbook.id === guestbook.id
+ ? { ...currentGuestbook, enabled: !currentGuestbook.enabled }
+ : currentGuestbook
+ )
+ )
+ toast.success(t('alerts.statusUpdated'))
+ } catch {
+ setToggleGuestbookError(t('errors.toggleEnabled'))
+ } finally {
+ setTogglingGuestbookId(undefined)
+ }
+ }
+
+ const handleDownloadResponses = async (guestbook: Guestbook) => {
+ setDownloadResponsesError(null)
+ setDownloadingGuestbookId(guestbook.id)
+
+ try {
+ const csvContent = await downloadGuestbookResponsesOfAGuestbook(
+ 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 {
+ setDownloadingGuestbookId(undefined)
+ }
+ }
+
+ const handleDownloadAllResponses = async () => {
+ if (!collection) {
+ return
+ }
+
+ setDownloadResponsesError(null)
+ setIsDownloadingAllResponses(true)
+
+ try {
+ const csvContent = await downloadGuestbookResponsesByDataverseId(
+ guestbookRepository,
+ collection.id
+ )
+ downloadFile(
+ csvContent,
+ `${collection.name}-all-guestbook-responses.csv`,
+ 'text/csv;charset=utf-8'
+ )
+ toast.success(t('alerts.downloadStarted'))
+ } catch {
+ setDownloadResponsesError(t('errors.downloadResponses'))
+ } finally {
+ setIsDownloadingAllResponses(false)
+ }
+ }
+
+ if (!isLoading && !collection) {
+ return
+ }
+
+ if (isLoading || isLoadingGuestbooksByCollectionId || !collection) {
+ return
+ }
+
+ return (
+
+
+
+
+
+ {guestbookToPreview && (
+ setGuestbookToPreview(undefined)}
+ guestbook={guestbookToPreview}
+ />
+ )}
+
+
+
+ {rootCollectionNames.length > 0 && (
+
setIncludeGuestbooksFromRoot((current) => !current)}
+ className={styles['include-templates-filter']}
+ />
+ )}
+
+
+
+
+
+
+ {errorGetGuestbooksByCollectionId && (
+ {errorGetGuestbooksByCollectionId}
+ )}
+ {toggleGuestbookError && {toggleGuestbookError}}
+ {downloadResponsesError && {downloadResponsesError}}
+
+ {filteredGuestbooks.length === 0 ? (
+
+ ) : (
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ {t('table.action')}
+ |
+
+
+
+ {sortedGuestbooks.map((guestbook) => (
+
+ | {guestbook.name} |
+ {new Date(guestbook.createTime).toLocaleDateString()} |
+ {guestbook.usageCount ?? 0} |
+ {guestbook.responseCount ?? 0} |
+
+ {guestbook.dataverseId !== currentDataverseId && (
+
+ {t('table.guestbookCreatedAt', { alias: guestbook.dataverseId })}
+
+ )}
+ setGuestbookToPreview(guestbook)}
+ onToggleEnabled={() => handleToggleEnabled(guestbook)}
+ isTogglingEnabled={togglingGuestbookId === guestbook.id}
+ onDownloadResponses={() => handleDownloadResponses(guestbook)}
+ isDownloadingResponses={downloadingGuestbookId === guestbook.id}
+ actionGroupClassName={styles['action-group']}
+ toggleStatusButtonClassName={styles['toggle-status-button']}
+ />
+ |
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx b/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx
new file mode 100644
index 000000000..bdb6579a4
--- /dev/null
+++ b/src/sections/guestbooks/action-buttons/GuestbookActionButtons.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Button, ButtonGroup, Tooltip } from '@iqss/dataverse-design-system'
+import { Download, Eye, Files, Pencil } from 'react-bootstrap-icons'
+import { NotImplementedModal } from '@/sections/not-implemented/NotImplementedModal'
+
+interface GuestbookActionButtonsProps {
+ isEnabled: boolean
+ onView: () => void
+ onToggleEnabled: () => void
+ isTogglingEnabled?: boolean
+ onDownloadResponses: () => void
+ isDownloadingResponses?: boolean
+ actionGroupClassName?: string
+ toggleStatusButtonClassName?: string
+}
+
+export const GuestbookActionButtons = ({
+ isEnabled,
+ onView,
+ onToggleEnabled,
+ isTogglingEnabled = false,
+ onDownloadResponses,
+ isDownloadingResponses = false,
+ actionGroupClassName,
+ toggleStatusButtonClassName
+}: GuestbookActionButtonsProps) => {
+ const { t } = useTranslation('guestbooks')
+ const [showNotImplementedModal, setShowNotImplementedModal] = useState(false)
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setShowNotImplementedModal(false)}
+ />
+ >
+ )
+}
diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbook.module.scss b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.module.scss
new file mode 100644
index 000000000..645f4e780
--- /dev/null
+++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.module.scss
@@ -0,0 +1,63 @@
+@import 'node_modules/bootstrap/scss/functions';
+@import 'node_modules/bootstrap/scss/variables';
+@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module';
+
+.form {
+ margin-top: 1.5rem;
+}
+
+.form-row {
+ margin-bottom: 2rem;
+}
+
+.row-label {
+ font-weight: 700;
+}
+
+.help {
+ color: $dv-subtext-color;
+ margin-bottom: 1rem;
+}
+
+.checkboxes {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.add-question-button {
+ min-width: 3rem;
+}
+
+.question-controls {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.question-block {
+ margin-bottom: 1rem;
+}
+
+.response-options {
+ margin-top: 0.75rem;
+}
+
+.option-row {
+ margin-top: 0.75rem;
+}
+
+.option-button {
+ min-width: 3rem;
+}
+
+.question-separator {
+ border-bottom: 1px solid rgba($dv-subtext-color, 0.2);
+ margin-top: 1rem;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 1rem;
+}
diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx
new file mode 100644
index 000000000..21008735d
--- /dev/null
+++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbook.tsx
@@ -0,0 +1,403 @@
+import { useState } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import { type CreateGuestbookDTO } from '@iqss/dataverse-client-javascript'
+import { Alert, Button, Col, Form, Row } from '@iqss/dataverse-design-system'
+import { type NavigateFunction, useNavigate } from 'react-router-dom'
+import { DashLg, PlusLg } from 'react-bootstrap-icons'
+import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository'
+import { GuestbookQuestionType } from '@/guestbooks/domain/models/Guestbook'
+import { RouteWithParams } from '@/sections/Route.enum'
+import { useCollection } from '@/sections/collection/useCollection'
+import { NotFoundPage } from '@/sections/not-found-page/NotFoundPage'
+import { BreadcrumbsGenerator } from '@/sections/shared/hierarchy/BreadcrumbsGenerator'
+import { useGuestbookRepository } from '../GuestbookRepositoryContext'
+import { GuestbookSkeleton } from '../GuestbookSkeleton'
+import { useCreateGuestbook } from './useCreateGuestbook'
+import styles from './CreateGuestbook.module.scss'
+
+interface CreateGuestbookProps {
+ collectionId: string
+ collectionRepository: CollectionRepository
+}
+
+interface CustomQuestionDraft {
+ id: number
+ type: GuestbookQuestionType
+ questionText: string
+ required: boolean
+ responseOptions: string[]
+}
+
+export const CreateGuestbook = ({ collectionId, collectionRepository }: CreateGuestbookProps) => {
+ const { t } = useTranslation('guestbooks')
+ const navigate: NavigateFunction = useNavigate()
+ const guestbookRepository = useGuestbookRepository()
+ const { collection, isLoading } = useCollection(collectionRepository, collectionId)
+ const [guestbookName, setGuestbookName] = useState('')
+ const [nameRequired, setNameRequired] = useState(false)
+ const [emailRequired, setEmailRequired] = useState(false)
+ const [institutionRequired, setInstitutionRequired] = useState(false)
+ const [positionRequired, setPositionRequired] = useState(false)
+ const [customQuestions, setCustomQuestions] = useState([
+ {
+ id: 1,
+ type: 'text',
+ questionText: '',
+ required: false,
+ responseOptions: ['']
+ }
+ ])
+ const guestbooksGuideUrl =
+ 'https://guides.dataverse.org/en/6.9/user/dataverse-management.html#dataset-guestbooks'
+ const guestbooksRoute = RouteWithParams.GUESTBOOKS(collectionId)
+ const navigateToGuestbooks = () => navigate(guestbooksRoute)
+ const { isCreatingGuestbook, errorCreatingGuestbook, handleCreateGuestbook } = useCreateGuestbook(
+ {
+ guestbookRepository,
+ collectionIdOrAlias: collectionId,
+ onSuccessfulCreate: navigateToGuestbooks
+ }
+ )
+
+ 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: false,
+ nameRequired,
+ emailRequired,
+ institutionRequired,
+ positionRequired,
+ customQuestions: customQuestions
+ .filter((question) => question.questionText.trim().length > 0)
+ .map((question, index) => ({
+ question: question.questionText.trim(),
+ required: question.required,
+ displayOrder: index,
+ type: question.type,
+ hidden: false,
+ optionValues:
+ question.type === 'options'
+ ? question.responseOptions
+ .filter((option) => option.trim().length > 0)
+ .map((option, optionIndex) => ({
+ value: option.trim(),
+ displayOrder: optionIndex
+ }))
+ : undefined
+ }))
+ })
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ void handleCreateGuestbook(buildGuestbookDTO())
+ }
+
+ if (!isLoading && !collection) {
+ return
+ }
+
+ if (isLoading || !collection) {
+ return
+ }
+
+ return (
+
+
+
+
+
+ }}
+ />
+
+
+ {errorCreatingGuestbook && {errorCreatingGuestbook}}
+
+
+
+ {t('create.fields.name.label')}
+
+
+ setGuestbookName(event.target.value)}
+ />
+
+
+
+
+
+ {t('create.fields.dataCollected.label')}
+
+
+ {t('create.fields.dataCollected.help')}
+
+
setNameRequired((current) => !current)}
+ />
+ setEmailRequired((current) => !current)}
+ />
+ setInstitutionRequired((current) => !current)}
+ />
+ setPositionRequired((current) => !current)}
+ />
+
+
+
+
+
+
+ {t('create.fields.customQuestions.label')}
+
+
+ {t('create.fields.customQuestions.help')}
+ {customQuestions.map((question, questionIndex) => (
+
+
+
+
+ {t('create.fields.customQuestions.typeLabel')}
+
+
+ updateQuestion(question.id, (current) => ({
+ ...current,
+ type: event.target.value as GuestbookQuestionType
+ }))
+ }>
+
+
+
+
+
+
+
+ {t('create.fields.customQuestions.questionText')}
+
+
+ updateQuestion(question.id, (current) => ({
+ ...current,
+ questionText: event.target.value
+ }))
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {question.type === 'options' && (
+
+
+
+
+
+
+
+ {t('create.fields.customQuestions.responseOptions')}
+
+
+
+ {question.responseOptions.map((responseOption, optionIndex) => (
+
+
+
+
+
+
+ updateQuestion(question.id, (current) => ({
+ ...current,
+ responseOptions: current.responseOptions.map((option, index) =>
+ index === optionIndex ? event.target.value : option
+ )
+ }))
+ }
+ />
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ updateQuestion(question.id, (current) => ({
+ ...current,
+ required: !current.required
+ }))
+ }
+ />
+
+ {questionIndex !== customQuestions.length - 1 && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbookButton.tsx b/src/sections/guestbooks/create-guestbooks/CreateGuestbookButton.tsx
new file mode 100644
index 000000000..ce99ff7cf
--- /dev/null
+++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbookButton.tsx
@@ -0,0 +1,25 @@
+import { useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { Button } from '@iqss/dataverse-design-system'
+import { PlusLg } from 'react-bootstrap-icons'
+import { RouteWithParams } from '@/sections/Route.enum'
+
+interface CreateGuestbookButtonProps {
+ collectionId: string
+ className?: string
+}
+
+export const CreateGuestbookButton = ({ collectionId, className }: CreateGuestbookButtonProps) => {
+ const { t } = useTranslation('guestbooks')
+ const navigate = useNavigate()
+
+ return (
+
+ )
+}
diff --git a/src/sections/guestbooks/create-guestbooks/CreateGuestbookFactory.tsx b/src/sections/guestbooks/create-guestbooks/CreateGuestbookFactory.tsx
new file mode 100644
index 000000000..39120d69b
--- /dev/null
+++ b/src/sections/guestbooks/create-guestbooks/CreateGuestbookFactory.tsx
@@ -0,0 +1,18 @@
+import { ReactElement } from 'react'
+import { useParams } from 'react-router-dom'
+import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository'
+import { CreateGuestbook } from './CreateGuestbook'
+
+const collectionRepository = new CollectionJSDataverseRepository()
+
+export class CreateGuestbookFactory {
+ static create(): ReactElement {
+ return
+ }
+}
+
+function CreateGuestbookWithParams() {
+ const { collectionId } = useParams<{ collectionId: string }>() as { collectionId: string }
+
+ return
+}
diff --git a/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts b/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts
new file mode 100644
index 000000000..03f348d3b
--- /dev/null
+++ b/src/sections/guestbooks/create-guestbooks/useCreateGuestbook.ts
@@ -0,0 +1,48 @@
+import { useState } from 'react'
+import { type CreateGuestbookDTO, WriteError } from '@iqss/dataverse-client-javascript'
+import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository'
+import { createGuestbook } from '@/guestbooks/domain/useCases/createGuestbook'
+import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler'
+
+interface UseCreateGuestbookProps {
+ guestbookRepository: GuestbookRepository
+ collectionIdOrAlias: number | string
+ onSuccessfulCreate?: (guestbookId: number) => void
+}
+
+export const useCreateGuestbook = ({
+ guestbookRepository,
+ collectionIdOrAlias,
+ onSuccessfulCreate
+}: UseCreateGuestbookProps) => {
+ const [isCreatingGuestbook, setIsCreatingGuestbook] = useState(false)
+ const [errorCreatingGuestbook, setErrorCreatingGuestbook] = useState(null)
+
+ const handleCreateGuestbook = async (guestbook: CreateGuestbookDTO) => {
+ setIsCreatingGuestbook(true)
+ setErrorCreatingGuestbook(null)
+
+ try {
+ const guestbookId = await createGuestbook(guestbookRepository, collectionIdOrAlias, guestbook)
+ onSuccessfulCreate?.(guestbookId)
+ return guestbookId
+ } catch (err: WriteError | unknown) {
+ if (err instanceof WriteError) {
+ const error = new JSDataverseWriteErrorHandler(err)
+ const formattedError =
+ error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage()
+ setErrorCreatingGuestbook(formattedError)
+ } else {
+ setErrorCreatingGuestbook('Something went wrong creating the guestbook. Try again later.')
+ }
+ } finally {
+ setIsCreatingGuestbook(false)
+ }
+ }
+
+ return {
+ isCreatingGuestbook,
+ errorCreatingGuestbook,
+ handleCreateGuestbook
+ }
+}
diff --git a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx
index 6286547ff..22515b790 100644
--- a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx
+++ b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx
@@ -8,12 +8,14 @@ interface UseGetGuestbooksByCollectionIdProps {
guestbookRepository: GuestbookRepository
collectionIdOrAlias?: number | string
autoFetch?: boolean
+ includeStats?: boolean
}
export const useGetGuestbooksByCollectionId = ({
guestbookRepository,
collectionIdOrAlias,
- autoFetch = true
+ autoFetch = true,
+ includeStats = false
}: UseGetGuestbooksByCollectionIdProps) => {
const [guestbooks, setGuestbooks] = useState([])
const [isLoadingGuestbooksByCollectionId, setIsLoadingGuestbooksByCollectionId] =
@@ -35,7 +37,8 @@ export const useGetGuestbooksByCollectionId = ({
try {
const fetchedGuestbooks = await guestbookRepository.getGuestbooksByCollectionId(
- collectionIdOrAlias
+ collectionIdOrAlias,
+ includeStats
)
setGuestbooks(Array.isArray(fetchedGuestbooks) ? fetchedGuestbooks : [])
} catch (err) {
@@ -53,7 +56,7 @@ export const useGetGuestbooksByCollectionId = ({
} finally {
setIsLoadingGuestbooksByCollectionId(false)
}
- }, [collectionIdOrAlias, guestbookRepository])
+ }, [collectionIdOrAlias, guestbookRepository, includeStats])
useEffect(() => {
if (autoFetch) {
diff --git a/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx b/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx
index 47e251c36..e8d60ae1e 100644
--- a/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx
+++ b/src/sections/shared/hierarchy/BreadcrumbsGenerator.tsx
@@ -1,4 +1,5 @@
import { Breadcrumb } from '@iqss/dataverse-design-system'
+import { Link } from 'react-router-dom'
import {
DvObjectType,
UpwardHierarchyNode
@@ -7,24 +8,33 @@ import { LinkToPage } from '../link-to-page/LinkToPage'
import { Route } from '../../Route.enum'
import styles from './BreadcrumbsGenerator.module.scss'
+interface ActionItem {
+ text: string
+ url?: string
+}
+
type BreadcrumbGeneratorProps =
| {
hierarchy: UpwardHierarchyNode
withActionItem?: false
actionItemText?: never
+ actionItems?: never
}
| {
hierarchy: UpwardHierarchyNode
withActionItem: true
actionItemText: string
+ actionItems?: ActionItem[]
}
export function BreadcrumbsGenerator({
hierarchy,
withActionItem,
- actionItemText
+ actionItemText,
+ actionItems
}: BreadcrumbGeneratorProps) {
const hierarchyArray = hierarchy.toArray()
+ const resolvedActionItems = withActionItem ? actionItems ?? [{ text: actionItemText }] : []
return (
@@ -69,7 +79,29 @@ export function BreadcrumbsGenerator({
)
})}
- {withActionItem && {actionItemText}}
+ {withActionItem &&
+ resolvedActionItems.map((item, index) => {
+ const isLast = index === resolvedActionItems.length - 1
+
+ if (isLast || !item.url) {
+ return (
+
+ {item.text}
+
+ )
+ }
+
+ return (
+
+ {item.text}
+
+ )
+ })}
)
}
diff --git a/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts b/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts
index 076a3155e..e9a2fc8a9 100644
--- a/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts
+++ b/src/stories/shared-mock-repositories/guestbook/GuestbookMockRepository.ts
@@ -1,4 +1,7 @@
-import { type Guestbook as JSDataverseGuestbook } from '@iqss/dataverse-client-javascript'
+import {
+ type CreateGuestbookDTO,
+ type Guestbook as JSDataverseGuestbook
+} from '@iqss/dataverse-client-javascript'
import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository'
import { Guestbook } from '@/guestbooks/domain/models/Guestbook'
@@ -20,7 +23,9 @@ export const storybookGuestbook: Guestbook = {
}
],
createTime: '2026-01-01T00:00:00.000Z',
- dataverseId: 1
+ dataverseId: 1,
+ usageCount: 7,
+ responseCount: 3
}
export const storybookClientGuestbooks: JSDataverseGuestbook[] = [
@@ -39,14 +44,40 @@ export const storybookClientGuestbooks: JSDataverseGuestbook[] = [
]
export class GuestbookMockRepository implements GuestbookRepository {
+ createGuestbook(_collectionIdOrAlias: number | string, _guestbook: CreateGuestbookDTO) {
+ return Promise.resolve(storybookGuestbook.id)
+ }
+
getGuestbook(_guestbookId: number): Promise {
return Promise.resolve(storybookGuestbook)
}
- getGuestbooksByCollectionId(_collectionIdOrAlias: number | string): Promise {
+ getGuestbooksByCollectionId(
+ _collectionIdOrAlias: number | string,
+ _includeStats?: boolean
+ ): Promise {
return Promise.resolve(storybookClientGuestbooks as Guestbook[])
}
+ setGuestbookEnabled(
+ _collectionIdOrAlias: number | string,
+ _guestbookId: number,
+ _enabled: boolean
+ ): Promise {
+ return Promise.resolve()
+ }
+
+ downloadGuestbookResponsesByDataverseId(_dataverseId: number | string): Promise {
+ return Promise.resolve('name,email\nJane Doe,jane@example.com')
+ }
+
+ downloadGuestbookResponsesOfAGuestbook(
+ _dataverseId: number | string,
+ _guestbookId: number
+ ): Promise {
+ return Promise.resolve('name,email\nJane Doe,jane@example.com')
+ }
+
assignDatasetGuestbook(_datasetId: number | string, _guestbookId: number): Promise {
return Promise.resolve()
}
diff --git a/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx b/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx
index 66095d974..0952748e7 100644
--- a/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx
+++ b/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithTermsAndGuestbookModal.spec.tsx
@@ -142,6 +142,7 @@ describe('DownloadWithTermsAndGuestbookModal', () => {
Promise.resolve('/api/v1/access/datafiles/10,11?token=test')
guestbookRepository = {
+ createGuestbook: cy.stub(),
getGuestbook: cy
.stub()
.as('getGuestbook')
@@ -149,6 +150,9 @@ describe('DownloadWithTermsAndGuestbookModal', () => {
return getGuestbookImpl(guestbookId)
}),
getGuestbooksByCollectionId: cy.stub().resolves([]),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: (_datasetId: number | string, _guestbookId: number) =>
Promise.resolve(),
removeDatasetGuestbook: (_datasetId: number | string) => Promise.resolve()
diff --git a/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx b/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx
index 42df75645..2ee871b68 100644
--- a/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx
+++ b/tests/component/sections/dataset/dataset-guestbook/DatasetGuestbook.spec.tsx
@@ -24,8 +24,12 @@ describe('DatasetGuestbook', () => {
beforeEach(() => {
guestbookRepository = {
+ createGuestbook: cy.stub(),
getGuestbook: cy.stub(),
getGuestbooksByCollectionId: cy.stub(),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub().resolves(undefined),
removeDatasetGuestbook: cy.stub().resolves(undefined)
}
diff --git a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx
index 7d91af925..3d755f384 100644
--- a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx
+++ b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx
@@ -101,8 +101,12 @@ describe('EditDatasetTerms', () => {
cy.viewport(1920, 1080)
licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses)
guestbookRepository = {
+ createGuestbook: cy.stub(),
getGuestbook: cy.stub(),
getGuestbooksByCollectionId: cy.stub().resolves(mockGuestbooks),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub().resolves(undefined),
removeDatasetGuestbook: cy.stub().resolves(undefined)
}
diff --git a/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx
index 3dff96744..97954970e 100644
--- a/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx
+++ b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx
@@ -56,6 +56,19 @@ const mockGuestbooks: Guestbook[] = [
}
]
+const disabledGuestbook: Guestbook = {
+ id: 3,
+ name: 'Disabled Guestbook',
+ enabled: false,
+ emailRequired: true,
+ nameRequired: false,
+ institutionRequired: false,
+ positionRequired: false,
+ customQuestions: [],
+ createTime: '2026-01-01T00:00:00.000Z',
+ dataverseId: 1
+}
+
describe('EditGuestbook', () => {
const withProviders = (component: ReactNode, dataset: Dataset) => {
datasetRepository.getByPersistentId = cy.stub().resolves(dataset)
@@ -83,8 +96,12 @@ describe('EditGuestbook', () => {
beforeEach(() => {
guestbookRepository = {
+ createGuestbook: cy.stub(),
getGuestbook: cy.stub(),
getGuestbooksByCollectionId: cy.stub().resolves(mockGuestbooks),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub().resolves(undefined),
removeDatasetGuestbook: cy.stub().resolves(undefined)
}
@@ -159,6 +176,22 @@ describe('EditGuestbook', () => {
cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled')
})
+ it('does not show disabled guestbooks in the list', () => {
+ ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).resolves([
+ ...mockGuestbooks,
+ disabledGuestbook
+ ])
+ const dataset = DatasetMother.create({ guestbookId: undefined })
+
+ cy.customMount(
+ withProviders(, dataset)
+ )
+
+ cy.findByLabelText('Data Request Guestbook').should('exist')
+ cy.findByLabelText('Secondary Guestbook').should('exist')
+ cy.findByLabelText('Disabled Guestbook').should('not.exist')
+ })
+
it('clears the selected guestbook when clicking Clear Selection', () => {
const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id })
diff --git a/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx
index 9e43b6594..ffb72606c 100644
--- a/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx
+++ b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx
@@ -10,8 +10,12 @@ describe('useAssignDatasetGuestbook', () => {
beforeEach(() => {
onSuccessfulAssignDatasetGuestbook = cy.stub().as('onSuccessfulAssignDatasetGuestbook')
guestbookRepository = {
+ createGuestbook: cy.stub(),
getGuestbook: cy.stub(),
getGuestbooksByCollectionId: cy.stub(),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub(),
removeDatasetGuestbook: cy.stub()
}
diff --git a/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx
index 038bb086f..b2af96ed9 100644
--- a/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx
+++ b/tests/component/sections/edit-dataset-terms/useRemoveDatasetGuestbook.spec.tsx
@@ -10,8 +10,12 @@ describe('useRemoveDatasetGuestbook', () => {
beforeEach(() => {
onSuccessfulRemoveDatasetGuestbook = cy.stub().as('onSuccessfulRemoveDatasetGuestbook')
guestbookRepository = {
+ createGuestbook: cy.stub(),
getGuestbook: cy.stub(),
getGuestbooksByCollectionId: cy.stub(),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub(),
removeDatasetGuestbook: cy.stub()
}
diff --git a/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx b/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx
index f02786603..465d1b263 100644
--- a/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx
+++ b/tests/component/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.spec.tsx
@@ -197,8 +197,12 @@ describe('AccessFileMenu', () => {
it('opens the guestbook modal before starting the file download', () => {
const guestbookRepository: GuestbookRepository = {
+ createGuestbook: cy.stub().resolves(1),
getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook),
getGuestbooksByCollectionId: cy.stub().resolves([]),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub().resolves(),
removeDatasetGuestbook: cy.stub().resolves()
}
@@ -328,8 +332,12 @@ describe('AccessFileMenu', () => {
it('opens the terms modal when custom terms exist without a guestbook', () => {
const guestbookRepository: GuestbookRepository = {
+ createGuestbook: cy.stub().resolves(1),
getGuestbook: cy.stub().as('getGuestbook').resolves(guestbook),
getGuestbooksByCollectionId: cy.stub().resolves([]),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub().resolves(),
removeDatasetGuestbook: cy.stub().resolves()
}
diff --git a/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx b/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx
new file mode 100644
index 000000000..af55808db
--- /dev/null
+++ b/tests/component/sections/guestbooks/GuestbookActionButtons.spec.tsx
@@ -0,0 +1,133 @@
+import { useState } from 'react'
+import { Guestbook } from '@/guestbooks/domain/models/Guestbook'
+import { GuestbookActionButtons } from '@/sections/guestbooks/action-buttons/GuestbookActionButtons'
+import { PreviewGuestbookModal } from '@/sections/guestbooks/preview-modal/PreviewGuestbookModal'
+
+const guestbook: Guestbook = {
+ id: 10,
+ name: 'Downloadable Guestbook',
+ enabled: true,
+ emailRequired: true,
+ nameRequired: true,
+ institutionRequired: false,
+ positionRequired: false,
+ customQuestions: [
+ {
+ question: 'How will you use this data?',
+ required: true,
+ displayOrder: 1,
+ type: 'text',
+ hidden: false
+ }
+ ],
+ createTime: '2026-01-01T00:00:00.000Z',
+ dataverseId: 17
+}
+
+const GuestbookActionButtonsTestWrapper = ({
+ isEnabled = true,
+ isTogglingEnabled = false,
+ isDownloadingResponses = false,
+ onToggleEnabled = () => {},
+ onDownloadResponses = () => {}
+}: {
+ isEnabled?: boolean
+ isTogglingEnabled?: boolean
+ isDownloadingResponses?: boolean
+ onToggleEnabled?: () => void
+ onDownloadResponses?: () => void
+}) => {
+ const [showPreview, setShowPreview] = useState(false)
+
+ return (
+ <>
+ setShowPreview(true)}
+ onToggleEnabled={onToggleEnabled}
+ isTogglingEnabled={isTogglingEnabled}
+ onDownloadResponses={onDownloadResponses}
+ isDownloadingResponses={isDownloadingResponses}
+ />
+ setShowPreview(false)}
+ guestbook={guestbook}
+ />
+ >
+ )
+}
+
+describe('GuestbookActionButtons', () => {
+ it('renders Disable when guestbook is enabled and triggers toggle handler', () => {
+ const onToggleEnabled = cy.stub().as('onToggleEnabled')
+
+ cy.customMount()
+
+ cy.findByRole('button', { name: 'Disable' }).click()
+ cy.get('@onToggleEnabled').should('have.been.calledOnce')
+ })
+
+ it('renders Enable when guestbook is disabled', () => {
+ cy.customMount()
+
+ cy.findByRole('button', { name: 'Enable' }).should('exist')
+ })
+
+ it('opens and closes the preview guestbook modal from the view button', () => {
+ cy.customMount()
+
+ cy.findByRole('button', { name: 'View' }).click()
+ cy.findByRole('dialog').should('be.visible')
+ cy.findByText('Preview Guestbook').should('exist')
+ cy.findByText('Downloadable Guestbook').should('exist')
+ cy.findByText(/How will you use this data\?/).should('exist')
+ cy.findByText('Close').click()
+ cy.findByRole('dialog').should('not.exist')
+ })
+
+ it('triggers download handler', () => {
+ const onDownloadResponses = cy.stub().as('onDownloadResponses')
+
+ cy.customMount()
+
+ cy.findByRole('button', { name: 'Download responses' }).click()
+ cy.get('@onDownloadResponses').should('have.been.calledOnce')
+ })
+
+ it('opens the not implemented modal from copy, edit, and view responses buttons', () => {
+ cy.customMount()
+
+ cy.findByRole('button', { name: 'Copy' }).click()
+ cy.findByText('Not Implemented').should('exist')
+ cy.findByText(/This feature is not implemented yet in the Modern version./i).should('exist')
+ cy.findByText('Close').click()
+ cy.findByText('Not Implemented').should('not.exist')
+
+ cy.findByRole('button', { name: 'Edit' }).click()
+ cy.findByText('Not Implemented').should('exist')
+ cy.findByText('Close').click()
+ cy.findByText('Not Implemented').should('not.exist')
+
+ cy.findByRole('button', { name: 'View Responses' }).click()
+ cy.findByText('Not Implemented').should('exist')
+ })
+
+ it('keeps toggle and download buttons enabled when loading flags are false', () => {
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: 'Disable' }).should('not.be.disabled')
+ cy.findByRole('button', { name: 'Download responses' }).should('not.be.disabled')
+ })
+
+ it('disables toggle and download buttons while actions are in progress', () => {
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: 'Disable' }).should('be.disabled')
+ cy.findByRole('button', { name: 'Download responses' }).should('be.disabled')
+ })
+})
diff --git a/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx b/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx
new file mode 100644
index 000000000..90a924dc0
--- /dev/null
+++ b/tests/component/sections/guestbooks/ManageGuestbooks.spec.tsx
@@ -0,0 +1,363 @@
+import { ReactNode, Suspense } from 'react'
+import { useTranslation } from 'react-i18next'
+import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository'
+import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother'
+import { Guestbook } from '@/guestbooks/domain/models/Guestbook'
+import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository'
+import { GuestbookRepositoryProvider } from '@/sections/guestbooks/GuestbookRepositoryProvider'
+import { Guestbooks } from '@/sections/guestbooks/ManageGuestbooks'
+
+describe('ManageGuestbooks', () => {
+ const collectionRepository = {} as CollectionRepository
+ let guestbookRepository: GuestbookRepository
+
+ const guestbook: Guestbook = {
+ id: 10,
+ name: 'Downloadable Guestbook',
+ enabled: true,
+ emailRequired: true,
+ nameRequired: true,
+ institutionRequired: false,
+ positionRequired: false,
+ customQuestions: [],
+ createTime: '2026-01-01T00:00:00.000Z',
+ dataverseId: 17,
+ usageCount: 5,
+ responseCount: 1
+ }
+ const rootGuestbook: Guestbook = {
+ id: 11,
+ name: 'Alpha Root Guestbook',
+ enabled: true,
+ emailRequired: true,
+ nameRequired: false,
+ institutionRequired: false,
+ positionRequired: false,
+ customQuestions: [],
+ createTime: '2025-01-01T00:00:00.000Z',
+ dataverseId: 1,
+ usageCount: 1,
+ responseCount: 2
+ }
+ const localGuestbookLater: Guestbook = {
+ id: 12,
+ name: 'zeta local guestbook',
+ enabled: true,
+ emailRequired: true,
+ nameRequired: false,
+ institutionRequired: false,
+ positionRequired: false,
+ customQuestions: [
+ { question: 'Q1', required: false, displayOrder: 1, type: 'text', hidden: false }
+ ],
+ createTime: '2027-01-01T00:00:00.000Z',
+ dataverseId: 17,
+ usageCount: 3,
+ responseCount: 4
+ }
+ const localGuestbookMostQuestions: Guestbook = {
+ id: 13,
+ name: 'Beta Local Guestbook',
+ enabled: false,
+ emailRequired: true,
+ nameRequired: false,
+ institutionRequired: false,
+ positionRequired: false,
+ customQuestions: [
+ { question: 'Q1', required: false, displayOrder: 1, type: 'text', hidden: false },
+ { question: 'Q2', required: false, displayOrder: 2, type: 'text', hidden: false }
+ ],
+ createTime: '2024-01-01T00:00:00.000Z',
+ dataverseId: 17,
+ usageCount: 8,
+ responseCount: 6
+ }
+
+ const TranslationPreloader = ({ children }: { children: ReactNode }) => {
+ useTranslation('guestbooks')
+
+ return <>{children}>
+ }
+
+ const defaultGuestbooks = [
+ guestbook,
+ rootGuestbook,
+ localGuestbookLater,
+ localGuestbookMostQuestions
+ ]
+
+ beforeEach(() => {
+ collectionRepository.getById = cy.stub().resolves(
+ CollectionMother.create({
+ id: '17',
+ name: 'Root'
+ })
+ )
+
+ guestbookRepository = {
+ createGuestbook: cy.stub(),
+ getGuestbook: cy.stub(),
+ getGuestbooksByCollectionId: cy.stub().resolves(defaultGuestbooks),
+ setGuestbookEnabled: cy.stub().as('setGuestbookEnabled').resolves(undefined),
+ downloadGuestbookResponsesByDataverseId: cy
+ .stub()
+ .as('downloadGuestbookResponsesByDataverseId')
+ .resolves('name,email\nJane Doe,jane@example.com'),
+ downloadGuestbookResponsesOfAGuestbook: cy
+ .stub()
+ .as('downloadGuestbookResponsesOfAGuestbook')
+ .resolves('name,email\nJane Doe,jane@example.com'),
+ assignDatasetGuestbook: cy.stub(),
+ removeDatasetGuestbook: cy.stub()
+ }
+
+ cy.window().then((win) => {
+ cy.stub(win.URL, 'createObjectURL').returns('blob:guestbook-download')
+ cy.stub(win.URL, 'revokeObjectURL')
+ })
+ })
+
+ const mountComponent = () =>
+ cy.customMount(
+
+
+
+
+
+
+
+ )
+
+ const getRenderedGuestbookNames = () =>
+ cy
+ .get('tbody tr td:first-child')
+ .then(($cells) => [...$cells].map((cell) => cell.textContent?.trim() ?? ''))
+
+ it('downloads guestbook responses from the guestbook page ui', () => {
+ const createElementSpy = cy.spy(document, 'createElement')
+
+ mountComponent()
+
+ cy.contains('tbody tr', 'Downloadable Guestbook')
+ .findByRole('button', { name: 'Download responses' })
+ .click()
+
+ cy.get('@downloadGuestbookResponsesOfAGuestbook').should('have.been.calledOnceWith', 17, 10)
+ cy.then(() => {
+ expect(createElementSpy).to.have.been.calledWith('a')
+ })
+ cy.window().then((win) => {
+ expect(win.URL['createObjectURL']).to.have.been.called
+ expect(win.URL['revokeObjectURL']).to.have.been.called
+ })
+ cy.findByText('Your download has started.').should('exist')
+ })
+
+ it('sorts guestbooks by name and toggles sort direction on repeated clicks', () => {
+ mountComponent()
+
+ cy.findByRole('button', { name: /Guestbook Name/i }).click()
+ cy.findByRole('button', { name: /Guestbook Name/i })
+ .should('have.attr', 'aria-pressed', 'true')
+ .invoke('attr', 'class')
+ .should('include', 'sort-button-active')
+ cy.findByRole('button', { name: /Guestbook Name/i })
+ .closest('th')
+ .invoke('attr', 'class')
+ .should('include', 'sort-header-active')
+ getRenderedGuestbookNames().should('deep.equal', [
+ 'Alpha Root Guestbook',
+ 'Beta Local Guestbook',
+ 'Downloadable Guestbook',
+ 'zeta local guestbook'
+ ])
+
+ cy.findByRole('button', { name: /Guestbook Name/i }).click()
+ getRenderedGuestbookNames().should('deep.equal', [
+ 'zeta local guestbook',
+ 'Downloadable Guestbook',
+ 'Beta Local Guestbook',
+ 'Alpha Root Guestbook'
+ ])
+ })
+
+ it('sorts guestbooks by created date', () => {
+ mountComponent()
+
+ cy.findByRole('button', { name: /Created/i }).click()
+
+ getRenderedGuestbookNames().should('deep.equal', [
+ 'Beta Local Guestbook',
+ 'Alpha Root Guestbook',
+ 'Downloadable Guestbook',
+ 'zeta local guestbook'
+ ])
+ })
+
+ it('sorts guestbooks by usage count', () => {
+ mountComponent()
+
+ cy.get('thead')
+ .findByRole('button', { name: /^Usage$/i })
+ .click()
+
+ getRenderedGuestbookNames().should('deep.equal', [
+ 'Alpha Root Guestbook',
+ 'zeta local guestbook',
+ 'Downloadable Guestbook',
+ 'Beta Local Guestbook'
+ ])
+ })
+
+ it('sorts guestbooks by response count', () => {
+ mountComponent()
+
+ cy.get('thead')
+ .findByRole('button', { name: /^Responses$/i })
+ .click()
+
+ getRenderedGuestbookNames().should('deep.equal', [
+ 'Downloadable Guestbook',
+ 'Alpha Root Guestbook',
+ 'zeta local guestbook',
+ 'Beta Local Guestbook'
+ ])
+ })
+
+ it('prefills usage and response counts from the guestbooks stats payload', () => {
+ mountComponent()
+
+ cy.get('tbody tr')
+ .eq(0)
+ .within(() => {
+ cy.get('td').eq(2).should('have.text', '5')
+ cy.get('td').eq(3).should('have.text', '1')
+ })
+
+ cy.get('tbody tr')
+ .eq(3)
+ .within(() => {
+ cy.get('td').eq(2).should('have.text', '8')
+ cy.get('td').eq(3).should('have.text', '6')
+ })
+
+ cy.wrap(
+ guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent
+ ).should('have.been.calledOnceWith', '17', true)
+ })
+
+ it('filters inherited guestbooks when include guestbooks from root is toggled', () => {
+ mountComponent()
+
+ cy.findByLabelText('Include Guestbooks from Root').click()
+ cy.findByText('Alpha Root Guestbook').should('not.exist')
+ cy.findByText('Downloadable Guestbook').should('exist')
+ cy.findByText('Beta Local Guestbook').should('exist')
+
+ cy.findByLabelText('Include Guestbooks from Root').click()
+ cy.findByText('Alpha Root Guestbook').should('exist')
+ })
+
+ it('opens and closes the preview guestbook modal from the page ui', () => {
+ mountComponent()
+
+ cy.findAllByRole('button', { name: 'View' }).first().click()
+ cy.findByRole('dialog').should('be.visible')
+ cy.findByText('Preview Guestbook').should('exist')
+ cy.findByText('Close').click()
+ cy.findByRole('dialog').should('not.exist')
+ })
+
+ it('downloads all guestbook responses from the dataverse use case', () => {
+ const createElementSpy = cy.spy(document, 'createElement')
+
+ mountComponent()
+
+ cy.findByText('Download All Responses').click()
+
+ cy.get('@downloadGuestbookResponsesByDataverseId').should('have.been.calledOnceWith', '17')
+ cy.then(() => {
+ expect(createElementSpy).to.have.been.calledWith('a')
+ })
+ cy.window().then((win) => {
+ expect(win.URL['createObjectURL']).to.have.been.called
+ expect(win.URL['revokeObjectURL']).to.have.been.called
+ })
+ cy.findByText('Your download has started.').should('exist')
+ })
+
+ it('toggles a guestbook through the setGuestbookEnabled use case and refreshes the table', () => {
+ mountComponent()
+
+ cy.contains('tbody tr', 'Downloadable Guestbook')
+ .findByRole('button', { name: 'Disable' })
+ .click()
+
+ cy.get('@setGuestbookEnabled').should('have.been.calledOnceWith', 17, 10, false)
+ cy.contains('tbody tr', 'Downloadable Guestbook')
+ .findByRole('button', { name: 'Enable' })
+ .should('exist')
+ cy.findByText('The guestbook status has been updated.').should('exist')
+ })
+
+ it('shows an error when toggling guestbook status fails', () => {
+ ;(guestbookRepository.setGuestbookEnabled as Cypress.Agent).rejects(
+ new Error('toggle failed')
+ )
+
+ mountComponent()
+
+ cy.contains('tbody tr', 'Downloadable Guestbook')
+ .findByRole('button', { name: 'Disable' })
+ .click()
+
+ cy.findByText(/Something went wrong updating the guestbook status. Try again later.*/i).should(
+ 'exist'
+ )
+ })
+
+ it('shows an error when guestbook response download fails', () => {
+ ;(
+ guestbookRepository.downloadGuestbookResponsesOfAGuestbook as Cypress.Agent
+ ).rejects(new Error('download failed'))
+
+ mountComponent()
+
+ cy.contains('tbody tr', 'Downloadable Guestbook')
+ .findByRole('button', { name: 'Download responses' })
+ .click()
+
+ cy.findByText(
+ /Something went wrong downloading guestbook responses. Try again later.*/i
+ ).should('exist')
+ })
+
+ it('shows an error when downloading all guestbook responses fails', () => {
+ ;(
+ guestbookRepository.downloadGuestbookResponsesByDataverseId as Cypress.Agent
+ ).rejects(new Error('download failed'))
+
+ mountComponent()
+
+ cy.findByText('Download All Responses').click()
+
+ cy.findByText(
+ /Something went wrong downloading guestbook responses. Try again later.*/i
+ ).should('exist')
+ })
+
+ it('shows an error alert when fetching guestbooks fails', () => {
+ ;(guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent).rejects(
+ new Error('unexpected')
+ )
+
+ mountComponent()
+
+ cy.findByRole('alert')
+ .should('exist')
+ .and(
+ 'contain.text',
+ 'Something went wrong getting guestbooks by collection id. Try again later.'
+ )
+ })
+})
diff --git a/tests/component/sections/guestbooks/create-guestbooks/useCreateGuestbook.spec.tsx b/tests/component/sections/guestbooks/create-guestbooks/useCreateGuestbook.spec.tsx
new file mode 100644
index 000000000..0ae1374b4
--- /dev/null
+++ b/tests/component/sections/guestbooks/create-guestbooks/useCreateGuestbook.spec.tsx
@@ -0,0 +1,104 @@
+import { act, renderHook } from '@testing-library/react'
+import { type CreateGuestbookDTO, WriteError } from '@iqss/dataverse-client-javascript'
+import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository'
+import { useCreateGuestbook } from '@/sections/guestbooks/create-guestbooks/useCreateGuestbook'
+import { createGuestbookRepositoryStub } from '../createGuestbookRepositoryStub'
+
+const guestbook: CreateGuestbookDTO = {
+ name: 'Test Guestbook',
+ enabled: false,
+ emailRequired: true,
+ nameRequired: true,
+ institutionRequired: false,
+ positionRequired: false,
+ customQuestions: [
+ {
+ question: 'How will you use this data?',
+ required: true,
+ displayOrder: 0,
+ type: 'text',
+ hidden: false
+ }
+ ]
+}
+
+describe('useCreateGuestbook', () => {
+ let guestbookRepository: GuestbookRepository
+ let onSuccessfulCreate: Cypress.Agent
+
+ beforeEach(() => {
+ guestbookRepository = createGuestbookRepositoryStub()
+ onSuccessfulCreate = cy.stub().as('onSuccessfulCreate')
+ })
+
+ it('creates guestbook and calls success callback', async () => {
+ const createGuestbookStub =
+ guestbookRepository.createGuestbook as Cypress.Agent
+ createGuestbookStub.resolves(123)
+
+ const { result } = renderHook(() =>
+ useCreateGuestbook({
+ guestbookRepository,
+ collectionIdOrAlias: 'root',
+ onSuccessfulCreate
+ })
+ )
+
+ await act(async () => {
+ await result.current.handleCreateGuestbook(guestbook)
+ })
+
+ expect(createGuestbookStub).to.have.been.calledOnceWith('root', guestbook)
+ expect(onSuccessfulCreate).to.have.been.calledOnceWith(123)
+ expect(result.current.errorCreatingGuestbook).to.deep.equal(null)
+ expect(result.current.isCreatingGuestbook).to.deep.equal(false)
+ })
+
+ it('sets formatted error when create fails with WriteError', async () => {
+ const writeError = new WriteError()
+ writeError.message = 'Request failed. Reason was: [400] Guestbook name is required'
+ const createGuestbookStub =
+ guestbookRepository.createGuestbook as Cypress.Agent
+ createGuestbookStub.rejects(writeError)
+
+ const { result } = renderHook(() =>
+ useCreateGuestbook({
+ guestbookRepository,
+ collectionIdOrAlias: 'root',
+ onSuccessfulCreate
+ })
+ )
+
+ await act(async () => {
+ await result.current.handleCreateGuestbook(guestbook)
+ })
+
+ expect(onSuccessfulCreate).to.not.have.been.called
+ expect(result.current.errorCreatingGuestbook).to.deep.equal('Guestbook name is required')
+ expect(result.current.isCreatingGuestbook).to.deep.equal(false)
+ })
+
+ it('sets default error when create fails with unknown error', async () => {
+ const createGuestbookStub =
+ guestbookRepository.createGuestbook as Cypress.Agent
+ createGuestbookStub.rejects(new Error('unexpected'))
+
+ const { result } = renderHook(() =>
+ useCreateGuestbook({
+ guestbookRepository,
+ collectionIdOrAlias: 'root',
+ onSuccessfulCreate
+ })
+ )
+
+ await act(async () => {
+ await result.current.handleCreateGuestbook(guestbook)
+ })
+
+ expect(onSuccessfulCreate).to.not.have.been.called
+ expect(result.current.errorCreatingGuestbook).to.deep.equal(
+ 'Something went wrong creating the guestbook. Try again later.'
+ )
+ expect(result.current.isCreatingGuestbook).to.deep.equal(false)
+ })
+})
diff --git a/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts b/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts
index a2378952b..68bac2f29 100644
--- a/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts
+++ b/tests/component/sections/guestbooks/createGuestbookRepositoryStub.ts
@@ -1,8 +1,12 @@
import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository'
export const createGuestbookRepositoryStub = (): GuestbookRepository => ({
+ createGuestbook: cy.stub(),
getGuestbook: cy.stub(),
getGuestbooksByCollectionId: cy.stub(),
+ setGuestbookEnabled: cy.stub(),
+ downloadGuestbookResponsesByDataverseId: cy.stub(),
+ downloadGuestbookResponsesOfAGuestbook: cy.stub(),
assignDatasetGuestbook: cy.stub(),
removeDatasetGuestbook: cy.stub()
})
diff --git a/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx b/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx
index 3b36f5ce6..133c74ed7 100644
--- a/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx
+++ b/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx
@@ -15,7 +15,9 @@ const guestbook: Guestbook = {
positionRequired: false,
customQuestions: [],
createTime: '2026-01-01T00:00:00.000Z',
- dataverseId: 1
+ dataverseId: 1,
+ usageCount: 2,
+ responseCount: 4
}
describe('useGetGuestbooksByCollectionId', () => {
@@ -44,7 +46,7 @@ describe('useGetGuestbooksByCollectionId', () => {
expect(result.current.guestbooks).to.deep.equal([guestbook])
})
- cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1)
+ cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1, false)
})
it('returns an empty array when request succeeds with a non-array payload', async () => {
@@ -66,7 +68,28 @@ describe('useGetGuestbooksByCollectionId', () => {
expect(result.current.guestbooks).to.deep.equal([])
})
- cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1)
+ cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1, false)
+ })
+
+ it('passes includeStats=true when explicitly requested', async () => {
+ const getGuestbooksByCollectionIdStub =
+ guestbookRepository.getGuestbooksByCollectionId as Cypress.Agent
+ getGuestbooksByCollectionIdStub.resolves([guestbook])
+
+ const { result } = renderHook(() =>
+ useGetGuestbooksByCollectionId({
+ guestbookRepository,
+ collectionIdOrAlias: 1,
+ includeStats: true
+ })
+ )
+
+ await waitFor(() => {
+ expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(false)
+ expect(result.current.guestbooks).to.deep.equal([guestbook])
+ })
+
+ cy.wrap(getGuestbooksByCollectionIdStub).should('have.been.calledOnceWith', 1, true)
})
it('does not fetch when collection id is undefined', async () => {