{collection.name}
@@ -330,7 +354,17 @@ export const DatasetTemplates = ({
className={styles['action-group']}
aria-label={t('table.action')}>
{template.isDefault ? (
-
+ {
+ if (isSettingDefault) return
+ const didUnset = await handleUnsetTemplateAsDefault()
+ if (didUnset) {
+ toggleDefaultTemplate(null)
+ }
+ }}>
{t('actions.default')}
@@ -338,7 +372,13 @@ export const DatasetTemplates = ({
{
+ const didSet = await handleSetTemplateAsDefault(template.id)
+ if (didSet) {
+ toggleDefaultTemplate(template.id)
+ }
+ }}
className={styles['make-default-button']}>
{t('actions.makeDefault')}
@@ -378,12 +418,11 @@ export const DatasetTemplates = ({
onSelect={(eventKey) =>
handleEditTemplateAction(template, eventKey as 'metadata' | 'terms')
}>
- {/* waiting for Edit Template api support */}
-
- {tDataset('datasetActionButtons.editDataset.metadata')}
+
+ {t('editTemplate.actions.metadata')}
-
- {tDataset('datasetActionButtons.editDataset.terms')}
+
+ {t('editTemplate.actions.terms')}
{
const { t } = useTranslation('datasetTemplates')
+ const { setIsLoading } = useLoading()
const { collection, isLoading: isLoadingCollection } = useCollection(
collectionRepository,
collectionId
@@ -30,6 +33,10 @@ export const CreateTemplate = ({
const isLoadingData = isLoadingCollection
+ useEffect(() => {
+ setIsLoading(isLoadingData)
+ }, [isLoadingData, setIsLoading])
+
if (!isLoadingCollection && !collection) {
return
}
@@ -40,11 +47,16 @@ export const CreateTemplate = ({
return (
-
+
{t('createTemplate.pageTitle')}
{
+ const { t } = useTranslation('datasetTemplates')
+ const { setIsLoading } = useLoading()
+ const { collection, isLoading: isLoadingCollection } = useCollection(
+ collectionRepository,
+ collectionId
+ )
+ const { template, isLoadingTemplate, errorGetTemplate } = useGetTemplate({
+ templateRepository,
+ templateId
+ })
+
+ const isLoadingData = isLoadingCollection || isLoadingTemplate
+
+ useEffect(() => {
+ setIsLoading(isLoadingData)
+ }, [isLoadingData, setIsLoading])
+
+ if (!isLoadingCollection && !collection) {
+ return
+ }
+
+ if (isLoadingData || !collection) {
+ return
+ }
+
+ if (errorGetTemplate || !template) {
+ return (
+
+
+ {errorGetTemplate ?? t('editTemplate.errors.loadingTemplate')}
+
+
+ )
+ }
+
+ return (
+
+
+
+ {collectionId}
+
+
+ {t('pageTitle')}
+
+ {t('editTemplate.metadataPageTitle')}
+
+
+ {t('editTemplate.metadataPageTitle')}
+
+
+
+ )
+}
diff --git a/src/sections/templates/edit-template-metadata/EditTemplateMetadataSkeleton.tsx b/src/sections/templates/edit-template-metadata/EditTemplateMetadataSkeleton.tsx
new file mode 100644
index 000000000..5a9e3cb9d
--- /dev/null
+++ b/src/sections/templates/edit-template-metadata/EditTemplateMetadataSkeleton.tsx
@@ -0,0 +1,25 @@
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'
+import { Col, Row } from '@iqss/dataverse-design-system'
+import { BreadcrumbsSkeleton } from '@/sections/shared/hierarchy/BreadcrumbsSkeleton'
+import 'react-loading-skeleton/dist/skeleton.css'
+
+export const EditTemplateMetadataSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/src/sections/templates/edit-template-terms/EditTemplateLicenseTerms.tsx b/src/sections/templates/edit-template-terms/EditTemplateLicenseTerms.tsx
new file mode 100644
index 000000000..f55838368
--- /dev/null
+++ b/src/sections/templates/edit-template-terms/EditTemplateLicenseTerms.tsx
@@ -0,0 +1,283 @@
+import { useEffect, useMemo, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useForm, Controller, FormProvider } from 'react-hook-form'
+import { Form, Row, Col, Button, Alert } from '@iqss/dataverse-design-system'
+import { CustomTerms } from '@/dataset/domain/models/Dataset'
+import { LicenseRepository } from '@/licenses/domain/repositories/LicenseRepository'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { Template } from '@/templates/domain/models/Template'
+import { useGetLicenses } from '@/sections/edit-dataset-terms/edit-license-and-terms/useGetLicenses'
+import { useUpdateTemplateLicenseTerms } from './useUpdateTemplateLicenseTerms'
+import styles from '@/sections/edit-dataset-terms/edit-license-and-terms/EditLicenseAndTerms.module.scss'
+
+const CUSTOM_LICENSE_VALUE = 'CUSTOM' as const
+
+interface FormData {
+ license: string
+ customTerms?: CustomTerms
+}
+
+interface EditTemplateLicenseTermsProps {
+ template: Template
+ templateRepository: TemplateRepository
+ licenseRepository: LicenseRepository
+ onSuccess: () => void
+ onCancel?: () => void
+ onFormStateChange?: (isDirty: boolean) => void
+}
+
+export function EditTemplateLicenseTerms({
+ template,
+ templateRepository,
+ licenseRepository,
+ onSuccess,
+ onCancel,
+ onFormStateChange
+}: EditTemplateLicenseTermsProps) {
+ const { t: tDataset } = useTranslation('dataset')
+ const { t: tShared } = useTranslation('shared')
+
+ const formContainerRef = useRef(null)
+
+ const { licenses, isLoadingLicenses, errorLicenses } = useGetLicenses({
+ licenseRepository,
+ autoFetch: true
+ })
+
+ const { handleUpdateLicenseTerms, isLoading, error } = useUpdateTemplateLicenseTerms({
+ templateRepository,
+ onSuccess
+ })
+
+ const initialCustomTerms = useMemo(() => {
+ if (template.termsOfUse.customTerms) {
+ return template.termsOfUse.customTerms
+ }
+ return {
+ termsOfUse: '',
+ confidentialityDeclaration: '',
+ specialPermissions: '',
+ restrictions: '',
+ citationRequirements: '',
+ depositorRequirements: '',
+ conditions: '',
+ disclaimer: ''
+ }
+ }, [template.termsOfUse.customTerms])
+
+ const licenseOptions = useMemo(() => {
+ const dynamicOptions = [...licenses]
+ .sort((a, b) => a.sortOrder - b.sortOrder)
+ .map((license) => ({
+ value: license.id.toString(),
+ label: license.name,
+ uri: license.uri,
+ iconUri: license.iconUri || '',
+ isDefault: license.isDefault
+ }))
+
+ dynamicOptions.push({
+ value: CUSTOM_LICENSE_VALUE,
+ label: tDataset('editTerms.datasetTerms.customTermsLabel'),
+ uri: '',
+ iconUri: '',
+ isDefault: template.termsOfUse.customTerms !== undefined
+ })
+
+ return dynamicOptions
+ }, [licenses, tDataset, template.termsOfUse.customTerms])
+
+ const defaultLicenseValue = useMemo(() => {
+ if (template.termsOfUse.customTerms !== undefined) {
+ return CUSTOM_LICENSE_VALUE
+ }
+ const matchingLicense = licenseOptions.find((option) => option.label === template.license?.name)
+ return matchingLicense?.value
+ }, [licenseOptions, template.license?.name, template.termsOfUse.customTerms])
+
+ const form = useForm({
+ defaultValues: {
+ license: defaultLicenseValue,
+ customTerms: initialCustomTerms
+ },
+ mode: 'onChange'
+ })
+
+ const {
+ control,
+ handleSubmit,
+ watch,
+ reset,
+ formState: { isValid, isDirty }
+ } = form
+
+ useEffect(() => {
+ onFormStateChange?.(isDirty)
+ }, [isDirty, onFormStateChange])
+
+ useEffect(() => {
+ if (!isLoadingLicenses && licenseOptions.length > 0) {
+ reset({
+ license: defaultLicenseValue,
+ customTerms: initialCustomTerms
+ })
+ }
+ }, [defaultLicenseValue, initialCustomTerms, isLoadingLicenses, licenseOptions.length, reset])
+
+ const watchedLicense = watch('license')
+ const currentLicenseOption = licenseOptions.find((option) => option.value === watchedLicense)
+ const isCustomTerms = watchedLicense === CUSTOM_LICENSE_VALUE
+
+ const customTermsFields = useMemo(() => {
+ return Object.keys(initialCustomTerms).map((fieldName) => ({
+ name: fieldName,
+ translationKey: fieldName,
+ required: fieldName === 'termsOfUse' && isCustomTerms,
+ rows: 4,
+ rules:
+ fieldName === 'termsOfUse' && isCustomTerms
+ ? { required: tDataset('editTerms.datasetTerms.customTermsRequired') }
+ : {}
+ }))
+ }, [initialCustomTerms, isCustomTerms, tDataset])
+
+ const onSubmit = async (data: FormData) => {
+ if (data.license === CUSTOM_LICENSE_VALUE) {
+ await handleUpdateLicenseTerms(template.id, {
+ customTerms: data.customTerms
+ })
+ } else {
+ const selectedLicense = licenseOptions.find((option) => option.value === data.license)
+ if (selectedLicense) {
+ await handleUpdateLicenseTerms(template.id, { name: selectedLicense.label })
+ }
+ }
+ }
+
+ return (
+
+
+
+
+ {tDataset(`termsTab.${field.translationKey}`)}
+
+ (
+
+
+
+
+ {field.required && (
+
+ {error?.message}
+
+ )}
+
+
+
+ )}
+ />
+
+ ))}
+ >
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {isLoading ? tShared('saving') : tShared('saveChanges')}
+
+ {onCancel && (
+
+ {tShared('close')}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/sections/templates/edit-template-terms/EditTemplateTerms.module.scss b/src/sections/templates/edit-template-terms/EditTemplateTerms.module.scss
new file mode 100644
index 000000000..8dd256dab
--- /dev/null
+++ b/src/sections/templates/edit-template-terms/EditTemplateTerms.module.scss
@@ -0,0 +1,3 @@
+.tab-container {
+ padding: 1.5rem 0;
+}
diff --git a/src/sections/templates/edit-template-terms/EditTemplateTermsFactory.tsx b/src/sections/templates/edit-template-terms/EditTemplateTermsFactory.tsx
deleted file mode 100644
index 62c2b90bb..000000000
--- a/src/sections/templates/edit-template-terms/EditTemplateTermsFactory.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { ReactElement } from 'react'
-import { useParams } from 'react-router-dom'
-import { TemplateJSDataverseRepository } from '@/templates/infrastructure/repositories/TemplateJSDataverseRepository'
-import { EditTemplateTerms } from './index'
-
-const templateRepository = new TemplateJSDataverseRepository()
-
-export class EditTemplateTermsFactory {
- static create(): ReactElement {
- return
- }
-}
-
-function EditTemplateTermsWithParams() {
- const { collectionId, templateId } = useParams<{
- collectionId: string
- templateId: string
- }>() as {
- collectionId: string
- templateId: string
- }
-
- return (
-
- )
-}
diff --git a/src/sections/templates/edit-template-terms/EditTemplateTermsOfAccess.tsx b/src/sections/templates/edit-template-terms/EditTemplateTermsOfAccess.tsx
new file mode 100644
index 000000000..186c1bf6d
--- /dev/null
+++ b/src/sections/templates/edit-template-terms/EditTemplateTermsOfAccess.tsx
@@ -0,0 +1,211 @@
+import { useEffect, useMemo, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useForm, Controller, FormProvider, useWatch } from 'react-hook-form'
+import { Form, Row, Col, Button, Alert } from '@iqss/dataverse-design-system'
+import { TermsOfAccess } from '@/dataset/domain/models/Dataset'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { Template } from '@/templates/domain/models/Template'
+import { useUpdateTemplateTermsOfAccess } from './useUpdateTemplateTermsOfAccess'
+import styles from '@/sections/edit-dataset-terms/edit-license-and-terms/EditLicenseAndTerms.module.scss'
+
+interface EditTemplateTermsOfAccessProps {
+ template: Template
+ templateRepository: TemplateRepository
+ onSuccess: () => void
+ onCancel?: () => void
+ onFormStateChange?: (isDirty: boolean) => void
+}
+
+export function EditTemplateTermsOfAccess({
+ template,
+ templateRepository,
+ onSuccess,
+ onCancel,
+ onFormStateChange
+}: EditTemplateTermsOfAccessProps) {
+ const { t: tDataset } = useTranslation('dataset')
+ const { t: tShared } = useTranslation('shared')
+
+ const defaultTermsOfAccess: TermsOfAccess = {
+ fileAccessRequest: false,
+ termsOfAccessForRestrictedFiles: undefined,
+ dataAccessPlace: undefined,
+ originalArchive: undefined,
+ availabilityStatus: undefined,
+ contactForAccess: undefined,
+ sizeOfCollection: undefined,
+ studyCompletion: undefined
+ }
+
+ const initialTermsOfAccess: TermsOfAccess =
+ template.termsOfUse.termsOfAccess ?? defaultTermsOfAccess
+ const formContainerRef = useRef(null)
+
+ const { handleUpdateTermsOfAccess, isLoading, error } = useUpdateTemplateTermsOfAccess({
+ templateRepository,
+ onSuccess
+ })
+
+ const form = useForm({
+ defaultValues: initialTermsOfAccess,
+ mode: 'onChange'
+ })
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isDirty }
+ } = form
+
+ useEffect(() => {
+ onFormStateChange?.(isDirty)
+ }, [isDirty, onFormStateChange])
+
+ useEffect(() => {
+ if (template.termsOfUse.termsOfAccess) {
+ reset(template.termsOfUse.termsOfAccess)
+ }
+ }, [template.termsOfUse.termsOfAccess, reset])
+
+ const fileAccessRequestValue = useWatch({ control, name: 'fileAccessRequest' })
+ const termsOfAccessForRestrictedFilesValue = useWatch({
+ control,
+ name: 'termsOfAccessForRestrictedFiles'
+ })
+ const isRequestAccessEnabled =
+ fileAccessRequestValue === undefined ? true : Boolean(fileAccessRequestValue)
+ const isTermsOfAccessProvided =
+ typeof termsOfAccessForRestrictedFilesValue === 'string' &&
+ termsOfAccessForRestrictedFilesValue.trim().length > 0
+
+ const termsOfAccessFields = useMemo(() => {
+ return Object.keys(initialTermsOfAccess)
+ .filter((fieldName) => fieldName !== 'fileAccessRequest')
+ .map((fieldName) => ({
+ name: fieldName,
+ translationKey:
+ fieldName === 'termsOfAccessForRestrictedFiles' ? 'termsOfAccess' : fieldName,
+ required: false,
+ rows: 4,
+ type: 'textarea',
+ rules: {}
+ }))
+ .map((field) => {
+ if (field.name === 'termsOfAccessForRestrictedFiles') {
+ const required = !isRequestAccessEnabled
+ return {
+ ...field,
+ required,
+ rules: {
+ validate: (value: string | boolean | undefined) =>
+ isRequestAccessEnabled ||
+ (typeof value === 'string' && value.trim().length > 0) ||
+ tDataset('termsTab.termsOfAccessRequiredWhenRequestDisabled')
+ }
+ }
+ }
+ return field
+ })
+ }, [initialTermsOfAccess, isRequestAccessEnabled, tDataset])
+
+ return (
+
+
+ {tDataset('termsTab.termsOfAccessInfo')}
+
+
+
+
+
+
+ {tDataset('termsTab.requestAccess')}
+
+
+
+ (
+
+
+
+ )}
+ />
+
+
+
+ {termsOfAccessFields.map((field) => (
+
+
+ {tDataset(`termsTab.${field.translationKey}`)}
+
+ (
+
+
+
+
+ {field.required && (
+ {error?.message}
+ )}
+
+
+
+ )}
+ />
+
+ ))}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {isLoading ? tShared('saving') : tShared('saveChanges')}
+
+ {onCancel && (
+
+ {tShared('close')}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/sections/templates/edit-template-terms/index.tsx b/src/sections/templates/edit-template-terms/index.tsx
index 0f5846fe6..2eac1dd5b 100644
--- a/src/sections/templates/edit-template-terms/index.tsx
+++ b/src/sections/templates/edit-template-terms/index.tsx
@@ -1,35 +1,42 @@
-// waiting to be implemented till Edit Template api support is ready
-
+import { useEffect } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import { Accordion, Button, Breadcrumb, Alert } from '@iqss/dataverse-design-system'
+import { Tabs, Breadcrumb, Alert } from '@iqss/dataverse-design-system'
import { RouteWithParams } from '@/sections/Route.enum'
import { useGetTemplate } from '@/templates/domain/hooks/useGetTemplate'
import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
-import { License } from '@/sections/dataset/dataset-terms/License'
-import { CustomTerms } from '@/sections/dataset/dataset-terms/CustomTerms'
-import { TermsOfAccess } from '@/sections/dataset/dataset-terms/TermsOfAccess'
-import { DatasetTermsRow } from '@/sections/dataset/dataset-terms/DatasetTermsRow'
+import { LicenseRepository } from '@/licenses/domain/repositories/LicenseRepository'
+import { EditTemplateLicenseTerms } from './EditTemplateLicenseTerms'
+import { EditTemplateTermsOfAccess } from './EditTemplateTermsOfAccess'
import styles from '../create-template/CreateTemplate.module.scss'
+import termsStyles from './EditTemplateTerms.module.scss'
import { EditTemplateTermsSkeleton } from './EditTemplateTermsSkeleton'
+import { useLoading } from '@/shared/contexts/loading/LoadingContext'
interface EditTemplateTermsProps {
collectionId: string
templateId: number
templateRepository: TemplateRepository
+ licenseRepository: LicenseRepository
}
export const EditTemplateTerms = ({
collectionId,
templateId,
- templateRepository
+ templateRepository,
+ licenseRepository
}: EditTemplateTermsProps) => {
const { t } = useTranslation('datasetTemplates')
const { t: tDataset } = useTranslation('dataset')
- const { t: tShared } = useTranslation('shared')
+ const { setIsLoading } = useLoading()
const navigate = useNavigate()
const location = useLocation()
+ const handleClose = () =>
+ navigate(RouteWithParams.COLLECTION_TEMPLATES(collectionId), {
+ state: { fromEditTemplate: true }
+ })
+
const { template, isLoadingTemplate, errorGetTemplate } = useGetTemplate({
templateRepository,
templateId
@@ -38,83 +45,63 @@ export const EditTemplateTerms = ({
(location.state as { fromCreateTemplate?: boolean } | null)?.fromCreateTemplate
)
+ useEffect(() => {
+ setIsLoading(isLoadingTemplate)
+ }, [isLoadingTemplate, setIsLoading])
+
if (isLoadingTemplate) {
return
}
return (
- <>
-
-
-
- {collectionId}
-
-
- {t('pageTitle')}
-
-
- {tDataset('datasetActionButtons.editDataset.terms')}
-
-
-
- {tDataset('datasetActionButtons.editDataset.terms')}
-
- {showCreateSuccess && (
-
- {t('createTemplate.alerts.success')}
-
- )}
- {errorGetTemplate && (
- {tShared('errors.loadingDataErrorMessage')}
- )}
- {!isLoadingTemplate && template && (
-
-
- {tDataset('termsTab.licenseTitle')}
-
-
-
-
-
-
- {tDataset('termsTab.termsTitle')}
-
-
-
-
-
-
- )}
-
-
- {tShared('saveChanges')}
-
- navigate(RouteWithParams.COLLECTION_TEMPLATES(collectionId))}>
- {tShared('cancel')}
-
-
-
- >
+
+
+
+ {collectionId}
+
+
+ {t('pageTitle')}
+
+ {t('editTemplate.termsPageTitle')}
+
+
+ {t('editTemplate.termsPageTitle')}
+
+ {showCreateSuccess && (
+
+ {t('createTemplate.alerts.success')}
+
+ )}
+ {errorGetTemplate && {errorGetTemplate} }
+ {!isLoadingTemplate && template && (
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
)
}
diff --git a/src/sections/templates/edit-template-terms/useUpdateTemplateLicenseTerms.ts b/src/sections/templates/edit-template-terms/useUpdateTemplateLicenseTerms.ts
new file mode 100644
index 000000000..f6a24e156
--- /dev/null
+++ b/src/sections/templates/edit-template-terms/useUpdateTemplateLicenseTerms.ts
@@ -0,0 +1,58 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { WriteError } from '@iqss/dataverse-client-javascript'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { UpdateTemplateLicenseTermsInfo } from '@/templates/domain/models/UpdateTemplateLicenseTermsInfo'
+import { updateTemplateLicenseTerms as updateTemplateLicenseTermsUseCase } from '@/templates/domain/useCases/updateTemplateLicenseTerms'
+import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler'
+
+interface Options {
+ templateRepository: TemplateRepository
+ onSuccess: () => void
+}
+
+interface ReturnType {
+ isLoading: boolean
+ error: string | null
+ handleUpdateLicenseTerms: (
+ templateId: number,
+ payload: UpdateTemplateLicenseTermsInfo
+ ) => Promise
+}
+
+export const useUpdateTemplateLicenseTerms = ({
+ templateRepository,
+ onSuccess
+}: Options): ReturnType => {
+ const { t } = useTranslation('datasetTemplates')
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleUpdateLicenseTerms = async (
+ templateId: number,
+ payload: UpdateTemplateLicenseTermsInfo
+ ): Promise => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ await updateTemplateLicenseTermsUseCase(templateRepository, templateId, payload)
+ onSuccess()
+ return true
+ } catch (err) {
+ console.error('useUpdateTemplateLicenseTerms error:', err)
+ if (err instanceof WriteError) {
+ const handler = new JSDataverseWriteErrorHandler(err)
+ setError(handler.getReasonWithoutStatusCode() ?? handler.getErrorMessage())
+ } else if (err instanceof Error && err.message) {
+ setError(err.message)
+ } else {
+ setError(t('editTemplate.errors.saveLicenseFailed'))
+ }
+ return false
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return { isLoading, error, handleUpdateLicenseTerms }
+}
diff --git a/src/sections/templates/edit-template-terms/useUpdateTemplateTermsOfAccess.ts b/src/sections/templates/edit-template-terms/useUpdateTemplateTermsOfAccess.ts
new file mode 100644
index 000000000..4e92ae862
--- /dev/null
+++ b/src/sections/templates/edit-template-terms/useUpdateTemplateTermsOfAccess.ts
@@ -0,0 +1,55 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { WriteError } from '@iqss/dataverse-client-javascript'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { TermsOfAccess } from '@/dataset/domain/models/Dataset'
+import { updateTemplateTermsOfAccess as updateTemplateTermsOfAccessUseCase } from '@/templates/domain/useCases/updateTemplateTermsOfAccess'
+import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler'
+
+interface Options {
+ templateRepository: TemplateRepository
+ onSuccess: () => void
+}
+
+interface ReturnType {
+ isLoading: boolean
+ error: string | null
+ handleUpdateTermsOfAccess: (templateId: number, termsOfAccess: TermsOfAccess) => Promise
+}
+
+export const useUpdateTemplateTermsOfAccess = ({
+ templateRepository,
+ onSuccess
+}: Options): ReturnType => {
+ const { t } = useTranslation('datasetTemplates')
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleUpdateTermsOfAccess = async (
+ templateId: number,
+ termsOfAccess: TermsOfAccess
+ ): Promise => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ await updateTemplateTermsOfAccessUseCase(templateRepository, templateId, termsOfAccess)
+ onSuccess()
+ return true
+ } catch (err) {
+ console.error('useUpdateTemplateTermsOfAccess error:', err)
+ if (err instanceof WriteError) {
+ const handler = new JSDataverseWriteErrorHandler(err)
+ setError(handler.getReasonWithoutStatusCode() ?? handler.getErrorMessage())
+ } else if (err instanceof Error && err.message) {
+ setError(err.message)
+ } else {
+ setError(t('editTemplate.errors.saveTermsOfAccessFailed'))
+ }
+ return false
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return { isLoading, error, handleUpdateTermsOfAccess }
+}
diff --git a/src/sections/templates/edit-template/EditTemplateFactory.tsx b/src/sections/templates/edit-template/EditTemplateFactory.tsx
new file mode 100644
index 000000000..408f22c30
--- /dev/null
+++ b/src/sections/templates/edit-template/EditTemplateFactory.tsx
@@ -0,0 +1,62 @@
+import { ReactElement } from 'react'
+import { useSearchParams, Navigate } from 'react-router-dom'
+import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository'
+import { MetadataBlockInfoJSDataverseRepository } from '@/metadata-block-info/infrastructure/repositories/MetadataBlockInfoJSDataverseRepository'
+import { TemplateJSDataverseRepository } from '@/templates/infrastructure/repositories/TemplateJSDataverseRepository'
+import { LicenseJSDataverseRepository } from '@/licenses/infrastructure/repositories/LicenseJSDataverseRepository'
+import { QueryParamKey, Route, TemplateEditMode } from '@/sections/Route.enum'
+import { EditTemplateMetadata } from '../edit-template-metadata/EditTemplateMetadata'
+import { EditTemplateTerms } from '../edit-template-terms'
+
+const collectionRepository = new CollectionJSDataverseRepository()
+const metadataBlockInfoRepository = new MetadataBlockInfoJSDataverseRepository()
+const templateRepository = new TemplateJSDataverseRepository()
+const licenseRepository = new LicenseJSDataverseRepository()
+
+export class EditTemplateFactory {
+ static create(): ReactElement {
+ return
+ }
+}
+
+function EditTemplateDispatcher() {
+ const [searchParams] = useSearchParams()
+ const id = searchParams.get(QueryParamKey.ID)
+ const ownerId = searchParams.get(QueryParamKey.OWNER_ID)
+ const editMode = searchParams.get(QueryParamKey.EDIT_MODE)
+
+ if (!id || !ownerId || !editMode) {
+ return
+ }
+
+ const templateId = Number(id)
+
+ if (Number.isNaN(templateId)) {
+ return
+ }
+
+ if (editMode === TemplateEditMode.METADATA) {
+ return (
+
+ )
+ }
+
+ if (editMode === TemplateEditMode.LICENSE) {
+ return (
+
+ )
+ }
+
+ return
+}
diff --git a/src/sections/templates/useCopyTemplate.ts b/src/sections/templates/useCopyTemplate.ts
index dddf362d1..87fdc1127 100644
--- a/src/sections/templates/useCopyTemplate.ts
+++ b/src/sections/templates/useCopyTemplate.ts
@@ -7,6 +7,9 @@ import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/reposi
import { type MetadataField } from '@/metadata-block-info/domain/models/MetadataBlockInfo'
import { getTemplate } from '@/templates/domain/useCases/getTemplate'
import { createTemplate } from '@/templates/domain/useCases/createTemplate'
+import { getTemplatesByCollectionId } from '@/templates/domain/useCases/getTemplatesByCollectionId'
+import { updateTemplateLicenseTerms } from '@/templates/domain/useCases/updateTemplateLicenseTerms'
+import { updateTemplateTermsOfAccess } from '@/templates/domain/useCases/updateTemplateTermsOfAccess'
import { getMetadataBlockInfoByCollectionId } from '@/metadata-block-info/domain/useCases/getMetadataBlockInfoByCollectionId'
import { MetadataFieldsHelper } from '@/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper'
import { TemplateInfo } from '@/templates/domain/models/TemplateInfo'
@@ -71,8 +74,9 @@ export const useCopyTemplate = ({
)
) ?? []
+ const copyName = t('copyNamePrefix', { name: template.name })
const templatePayload: TemplateInfo = {
- name: t('copyNamePrefix', { name: template.name }),
+ name: copyName,
isDefault: template.isDefault,
fields: templateFields,
instructions: template.instructions
@@ -80,6 +84,34 @@ export const useCopyTemplate = ({
await createTemplate(templateRepository, templatePayload, collectionId)
+ const templates = await getTemplatesByCollectionId(templateRepository, collectionId)
+ const copiedTemplate = templates
+ .filter((currentTemplate) => currentTemplate.id !== template.id)
+ .filter((currentTemplate) => currentTemplate.name === copyName)
+ .sort((firstTemplate, secondTemplate) => secondTemplate.id - firstTemplate.id)[0]
+
+ if (!copiedTemplate) {
+ throw new Error(t('alerts.copyError'))
+ }
+
+ if (template.termsOfUse.customTerms) {
+ await updateTemplateLicenseTerms(templateRepository, copiedTemplate.id, {
+ customTerms: template.termsOfUse.customTerms
+ })
+ } else if (template.license) {
+ await updateTemplateLicenseTerms(templateRepository, copiedTemplate.id, {
+ name: template.license.name
+ })
+ }
+
+ if (template.termsOfUse.termsOfAccess) {
+ await updateTemplateTermsOfAccess(
+ templateRepository,
+ copiedTemplate.id,
+ template.termsOfUse.termsOfAccess
+ )
+ }
+
toast.success(t('alerts.copySuccess'))
return true
} catch (error) {
diff --git a/src/sections/templates/useSetTemplateAsDefault.ts b/src/sections/templates/useSetTemplateAsDefault.ts
new file mode 100644
index 000000000..c5dc3be04
--- /dev/null
+++ b/src/sections/templates/useSetTemplateAsDefault.ts
@@ -0,0 +1,74 @@
+import { useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { WriteError } from '@iqss/dataverse-client-javascript'
+import { toast } from 'react-toastify'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { setTemplateAsDefault } from '@/templates/domain/useCases/setTemplateAsDefault'
+import { unsetTemplateAsDefault } from '@/templates/domain/useCases/unsetTemplateAsDefault'
+import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler'
+
+interface UseSetTemplateAsDefaultProps {
+ collectionId: string
+ templateRepository: TemplateRepository
+}
+
+interface UseSetTemplateAsDefaultResult {
+ handleSetTemplateAsDefault: (templateId: number) => Promise
+ handleUnsetTemplateAsDefault: () => Promise
+ isSettingDefault: boolean
+}
+
+export const useSetTemplateAsDefault = ({
+ collectionId,
+ templateRepository
+}: UseSetTemplateAsDefaultProps): UseSetTemplateAsDefaultResult => {
+ const { t } = useTranslation('datasetTemplates')
+ const [isSettingDefault, setIsSettingDefault] = useState(false)
+
+ const handleSetTemplateAsDefault = useCallback(
+ async (templateId: number) => {
+ setIsSettingDefault(true)
+
+ try {
+ await setTemplateAsDefault(templateRepository, templateId, collectionId)
+ toast.success(t('alerts.setDefaultSuccess'))
+ return true
+ } catch (error) {
+ if (error instanceof WriteError) {
+ const handler = new JSDataverseWriteErrorHandler(error)
+ const reason = handler.getReasonWithoutStatusCode() ?? handler.getErrorMessage()
+ toast.error(reason)
+ } else {
+ toast.error(t('alerts.setDefaultError'))
+ }
+ return false
+ } finally {
+ setIsSettingDefault(false)
+ }
+ },
+ [collectionId, t, templateRepository]
+ )
+
+ const handleUnsetTemplateAsDefault = useCallback(async () => {
+ setIsSettingDefault(true)
+
+ try {
+ await unsetTemplateAsDefault(templateRepository, collectionId)
+ toast.success(t('alerts.unsetDefaultSuccess'))
+ return true
+ } catch (error) {
+ if (error instanceof WriteError) {
+ const handler = new JSDataverseWriteErrorHandler(error)
+ const reason = handler.getReasonWithoutStatusCode() ?? handler.getErrorMessage()
+ toast.error(reason)
+ } else {
+ toast.error(t('alerts.unsetDefaultError'))
+ }
+ return false
+ } finally {
+ setIsSettingDefault(false)
+ }
+ }, [collectionId, t, templateRepository])
+
+ return { handleSetTemplateAsDefault, handleUnsetTemplateAsDefault, isSettingDefault }
+}
diff --git a/src/stories/templates/TemplateMockRepository.ts b/src/stories/templates/TemplateMockRepository.ts
index f0b2047d4..a9a6759bb 100644
--- a/src/stories/templates/TemplateMockRepository.ts
+++ b/src/stories/templates/TemplateMockRepository.ts
@@ -1,6 +1,9 @@
import { TemplateInfo } from '@/templates/domain/models/TemplateInfo'
+import { UpdateTemplateMetadataInfo } from '@/templates/domain/models/UpdateTemplateMetadataInfo'
+import { UpdateTemplateLicenseTermsInfo } from '@/templates/domain/models/UpdateTemplateLicenseTermsInfo'
import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
import { Template } from '@/templates/domain/models/Template'
+import { TermsOfAccess } from '@/dataset/domain/models/Dataset'
import { TemplateMother } from '@tests/component/sections/templates/TemplateMother'
import { FakerHelper } from '@tests/component/shared/FakerHelper'
@@ -36,4 +39,51 @@ export class TemplateMockRepository implements TemplateRepository {
}, FakerHelper.loadingTimout())
})
}
+
+ setTemplateAsDefault(_templateId: number, _collectionIdOrAlias: number | string): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve()
+ }, FakerHelper.loadingTimout())
+ })
+ }
+
+ unsetTemplateAsDefault(_collectionIdOrAlias: number | string): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve()
+ }, FakerHelper.loadingTimout())
+ })
+ }
+
+ updateTemplateMetadata(
+ _templateId: number,
+ _payload: UpdateTemplateMetadataInfo,
+ _replace?: boolean
+ ): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve()
+ }, FakerHelper.loadingTimout())
+ })
+ }
+
+ updateTemplateLicenseTerms(
+ _templateId: number,
+ _payload: UpdateTemplateLicenseTermsInfo
+ ): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve()
+ }, FakerHelper.loadingTimout())
+ })
+ }
+
+ updateTemplateTermsOfAccess(_templateId: number, _termsOfAccess: TermsOfAccess): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve()
+ }, FakerHelper.loadingTimout())
+ })
+ }
}
diff --git a/src/templates/domain/hooks/useGetTemplatesByCollectionId.ts b/src/templates/domain/hooks/useGetTemplatesByCollectionId.ts
index c7a3eb1c0..4af052916 100644
--- a/src/templates/domain/hooks/useGetTemplatesByCollectionId.ts
+++ b/src/templates/domain/hooks/useGetTemplatesByCollectionId.ts
@@ -53,10 +53,20 @@ export const useGetTemplatesByCollectionId = ({
void fetchDatasetTemplates()
}, [fetchDatasetTemplates])
+ const toggleDefaultTemplate = (templateId: number | null) => {
+ setDatasetTemplates((prev) =>
+ prev.map((t) => ({
+ ...t,
+ isDefault: t.id === templateId
+ }))
+ )
+ }
+
return {
datasetTemplates,
isLoadingDatasetTemplates,
errorGetDatasetTemplates,
- fetchDatasetTemplates
+ fetchDatasetTemplates,
+ toggleDefaultTemplate
}
}
diff --git a/src/templates/domain/models/UpdateTemplateLicenseTermsInfo.ts b/src/templates/domain/models/UpdateTemplateLicenseTermsInfo.ts
new file mode 100644
index 000000000..cd7817ee8
--- /dev/null
+++ b/src/templates/domain/models/UpdateTemplateLicenseTermsInfo.ts
@@ -0,0 +1,6 @@
+import { CustomTerms } from '@/dataset/domain/models/Dataset'
+
+export interface UpdateTemplateLicenseTermsInfo {
+ name?: string
+ customTerms?: CustomTerms
+}
diff --git a/src/templates/domain/models/UpdateTemplateMetadataInfo.ts b/src/templates/domain/models/UpdateTemplateMetadataInfo.ts
new file mode 100644
index 000000000..bba8ca56a
--- /dev/null
+++ b/src/templates/domain/models/UpdateTemplateMetadataInfo.ts
@@ -0,0 +1,7 @@
+import { TemplateFieldInfo, TemplateInstructionInfo } from './TemplateInfo'
+
+export interface UpdateTemplateMetadataInfo {
+ name?: string
+ fields?: TemplateFieldInfo[]
+ instructions?: TemplateInstructionInfo[]
+}
diff --git a/src/templates/domain/repositories/TemplateRepository.ts b/src/templates/domain/repositories/TemplateRepository.ts
index 9e653c281..cc19e5e1d 100644
--- a/src/templates/domain/repositories/TemplateRepository.ts
+++ b/src/templates/domain/repositories/TemplateRepository.ts
@@ -1,9 +1,24 @@
import { Template } from '@/templates/domain/models/Template'
import { TemplateInfo } from '@/templates/domain/models/TemplateInfo'
+import { UpdateTemplateMetadataInfo } from '@/templates/domain/models/UpdateTemplateMetadataInfo'
+import { UpdateTemplateLicenseTermsInfo } from '@/templates/domain/models/UpdateTemplateLicenseTermsInfo'
+import { TermsOfAccess } from '@/dataset/domain/models/Dataset'
export interface TemplateRepository {
createTemplate: (template: TemplateInfo, collectionIdOrAlias: number | string) => Promise
getTemplate: (templateId: number) => Promise
getTemplatesByCollectionId: (collectionIdOrAlias: number | string) => Promise
deleteTemplate: (templateId: number) => Promise
+ setTemplateAsDefault: (templateId: number, collectionIdOrAlias: number | string) => Promise
+ unsetTemplateAsDefault: (collectionIdOrAlias: number | string) => Promise
+ updateTemplateMetadata: (
+ templateId: number,
+ payload: UpdateTemplateMetadataInfo,
+ replace?: boolean
+ ) => Promise
+ updateTemplateLicenseTerms: (
+ templateId: number,
+ payload: UpdateTemplateLicenseTermsInfo
+ ) => Promise
+ updateTemplateTermsOfAccess: (templateId: number, termsOfAccess: TermsOfAccess) => Promise
}
diff --git a/src/templates/domain/useCases/setTemplateAsDefault.ts b/src/templates/domain/useCases/setTemplateAsDefault.ts
new file mode 100644
index 000000000..bef69ecb1
--- /dev/null
+++ b/src/templates/domain/useCases/setTemplateAsDefault.ts
@@ -0,0 +1,9 @@
+import { TemplateRepository } from '../repositories/TemplateRepository'
+
+export function setTemplateAsDefault(
+ templateRepository: TemplateRepository,
+ templateId: number,
+ collectionIdOrAlias: number | string
+): Promise {
+ return templateRepository.setTemplateAsDefault(templateId, collectionIdOrAlias)
+}
diff --git a/src/templates/domain/useCases/unsetTemplateAsDefault.ts b/src/templates/domain/useCases/unsetTemplateAsDefault.ts
new file mode 100644
index 000000000..45decf4de
--- /dev/null
+++ b/src/templates/domain/useCases/unsetTemplateAsDefault.ts
@@ -0,0 +1,8 @@
+import { TemplateRepository } from '../repositories/TemplateRepository'
+
+export function unsetTemplateAsDefault(
+ templateRepository: TemplateRepository,
+ collectionIdOrAlias: number | string
+): Promise {
+ return templateRepository.unsetTemplateAsDefault(collectionIdOrAlias)
+}
diff --git a/src/templates/domain/useCases/updateTemplateLicenseTerms.ts b/src/templates/domain/useCases/updateTemplateLicenseTerms.ts
new file mode 100644
index 000000000..34e7c4de1
--- /dev/null
+++ b/src/templates/domain/useCases/updateTemplateLicenseTerms.ts
@@ -0,0 +1,10 @@
+import { UpdateTemplateLicenseTermsInfo } from '@/templates/domain/models/UpdateTemplateLicenseTermsInfo'
+import { TemplateRepository } from '../repositories/TemplateRepository'
+
+export function updateTemplateLicenseTerms(
+ templateRepository: TemplateRepository,
+ templateId: number,
+ payload: UpdateTemplateLicenseTermsInfo
+): Promise {
+ return templateRepository.updateTemplateLicenseTerms(templateId, payload)
+}
diff --git a/src/templates/domain/useCases/updateTemplateMetadata.ts b/src/templates/domain/useCases/updateTemplateMetadata.ts
new file mode 100644
index 000000000..96c84389c
--- /dev/null
+++ b/src/templates/domain/useCases/updateTemplateMetadata.ts
@@ -0,0 +1,11 @@
+import { UpdateTemplateMetadataInfo } from '@/templates/domain/models/UpdateTemplateMetadataInfo'
+import { TemplateRepository } from '../repositories/TemplateRepository'
+
+export function updateTemplateMetadata(
+ templateRepository: TemplateRepository,
+ templateId: number,
+ payload: UpdateTemplateMetadataInfo,
+ replace = true
+): Promise {
+ return templateRepository.updateTemplateMetadata(templateId, payload, replace)
+}
diff --git a/src/templates/domain/useCases/updateTemplateTermsOfAccess.ts b/src/templates/domain/useCases/updateTemplateTermsOfAccess.ts
new file mode 100644
index 000000000..5729314e9
--- /dev/null
+++ b/src/templates/domain/useCases/updateTemplateTermsOfAccess.ts
@@ -0,0 +1,10 @@
+import { TermsOfAccess } from '@/dataset/domain/models/Dataset'
+import { TemplateRepository } from '../repositories/TemplateRepository'
+
+export function updateTemplateTermsOfAccess(
+ templateRepository: TemplateRepository,
+ templateId: number,
+ termsOfAccess: TermsOfAccess
+): Promise {
+ return templateRepository.updateTemplateTermsOfAccess(templateId, termsOfAccess)
+}
diff --git a/src/templates/infrastructure/repositories/TemplateJSDataverseRepository.ts b/src/templates/infrastructure/repositories/TemplateJSDataverseRepository.ts
index de86ea299..39da20b4d 100644
--- a/src/templates/infrastructure/repositories/TemplateJSDataverseRepository.ts
+++ b/src/templates/infrastructure/repositories/TemplateJSDataverseRepository.ts
@@ -2,10 +2,18 @@ import {
createTemplate,
deleteTemplate,
getTemplate,
- getTemplatesByCollectionId
+ getTemplatesByCollectionId,
+ setTemplateAsDefault,
+ unsetTemplateAsDefault,
+ updateTemplateMetadata,
+ updateTemplateLicenseTerms,
+ updateTemplateTermsOfAccess
} from '@iqss/dataverse-client-javascript'
import { Template } from '@/templates/domain/models/Template'
import { TemplateInfo } from '@/templates/domain/models/TemplateInfo'
+import { UpdateTemplateMetadataInfo } from '@/templates/domain/models/UpdateTemplateMetadataInfo'
+import { UpdateTemplateLicenseTermsInfo } from '@/templates/domain/models/UpdateTemplateLicenseTermsInfo'
+import { TermsOfAccess } from '@/dataset/domain/models/Dataset'
import { TemplateRepository } from '../../domain/repositories/TemplateRepository'
export class TemplateJSDataverseRepository implements TemplateRepository {
@@ -27,4 +35,35 @@ export class TemplateJSDataverseRepository implements TemplateRepository {
deleteTemplate(templateId: number): Promise {
return deleteTemplate.execute(templateId)
}
+
+ setTemplateAsDefault(templateId: number, collectionIdOrAlias: number | string): Promise {
+ return setTemplateAsDefault.execute(templateId, collectionIdOrAlias)
+ }
+
+ unsetTemplateAsDefault(collectionIdOrAlias: number | string): Promise {
+ return unsetTemplateAsDefault.execute(collectionIdOrAlias)
+ }
+
+ updateTemplateMetadata(
+ templateId: number,
+ payload: UpdateTemplateMetadataInfo,
+ replace = true
+ ): Promise {
+ return updateTemplateMetadata.execute(
+ templateId,
+ payload as Parameters[1],
+ replace
+ )
+ }
+
+ updateTemplateLicenseTerms(
+ templateId: number,
+ payload: UpdateTemplateLicenseTermsInfo
+ ): Promise {
+ return updateTemplateLicenseTerms.execute(templateId, payload)
+ }
+
+ updateTemplateTermsOfAccess(templateId: number, termsOfAccess: TermsOfAccess): Promise {
+ return updateTemplateTermsOfAccess.execute(templateId, termsOfAccess)
+ }
}
diff --git a/tests/component/sections/dataset/dataset-action-buttons/link-and-unlink-actions/LinkAndUnlinkActions.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/link-and-unlink-actions/LinkAndUnlinkActions.spec.tsx
new file mode 100644
index 000000000..f389d1794
--- /dev/null
+++ b/tests/component/sections/dataset/dataset-action-buttons/link-and-unlink-actions/LinkAndUnlinkActions.spec.tsx
@@ -0,0 +1,105 @@
+import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository'
+import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository'
+import { LinkAndUnlinkActions } from '@/sections/dataset/dataset-action-buttons/link-and-unlink-actions/LinkAndUnlinkActions'
+import { CollectionSummaryMother } from '@tests/component/collection/domain/models/CollectionSummaryMother'
+import {
+ DatasetMother,
+ DatasetVersionMother
+} from '@tests/component/dataset/domain/models/DatasetMother'
+import { WithRepositories } from '@tests/component/WithRepositories'
+
+const collectionRepository: CollectionRepository = {} as CollectionRepository
+const datasetRepository: DatasetRepository = {} as DatasetRepository
+
+const dataset = DatasetMother.create({
+ version: DatasetVersionMother.createReleased()
+})
+
+const linkedCollection = CollectionSummaryMother.create({
+ id: 1,
+ displayName: 'Collection 1',
+ alias: 'collection-1'
+})
+
+const linkableCollection = CollectionSummaryMother.create({
+ id: 3,
+ displayName: 'Collection 3',
+ alias: 'collection-3'
+})
+
+const mountAuthenticatedLinkAndUnlinkActions = () =>
+ cy.mountAuthenticated(
+
+
+
+ )
+
+describe('LinkAndUnlinkActions', () => {
+ beforeEach(() => {
+ cy.viewport('macbook-15')
+ datasetRepository.link = cy.stub().as('linkDataset').resolves()
+ datasetRepository.unlink = cy.stub().as('unlinkDataset').resolves()
+ })
+
+ it('remounts the actions after linking a dataset successfully', () => {
+ datasetRepository.getDatasetLinkedCollections = cy
+ .stub()
+ .as('getDatasetLinkedCollections')
+ .onCall(0)
+ .resolves([])
+ .onCall(1)
+ .resolves([])
+ .onCall(2)
+ .resolves([linkedCollection])
+ collectionRepository.getForLinking = cy.stub().resolves([linkableCollection])
+ collectionRepository.getForUnlinking = cy.stub().resolves([linkedCollection])
+
+ mountAuthenticatedLinkAndUnlinkActions()
+
+ cy.findByRole('button', { name: 'Unlink Dataset' }).should('not.exist')
+
+ cy.findByRole('button', { name: 'Link Dataset' }).click()
+
+ cy.findByRole('dialog')
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('textbox', { name: 'Your Collection' }).should('have.value', 'Collection 3')
+ })
+
+ cy.findByTestId('confirm-link-dataset-button').click()
+
+ cy.get('@linkDataset').should('have.been.calledOnce')
+ cy.get('@getDatasetLinkedCollections').should('have.callCount', 3)
+ cy.findByRole('button', { name: 'Unlink Dataset' }).should('exist')
+ })
+
+ it('remounts the actions after unlinking a dataset successfully', () => {
+ datasetRepository.getDatasetLinkedCollections = cy
+ .stub()
+ .as('getDatasetLinkedCollections')
+ .onCall(0)
+ .resolves([linkedCollection])
+ .onCall(1)
+ .resolves([])
+ collectionRepository.getForLinking = cy.stub().resolves([linkableCollection])
+ collectionRepository.getForUnlinking = cy.stub().resolves([linkedCollection])
+
+ mountAuthenticatedLinkAndUnlinkActions()
+
+ cy.findByRole('button', { name: 'Unlink Dataset' }).should('exist').click()
+
+ cy.findByRole('dialog')
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('textbox', { name: 'Your Collection' }).should('have.value', 'Collection 1')
+ })
+
+ cy.findByTestId('confirm-unlink-dataset-button').click()
+
+ cy.get('@unlinkDataset').should('have.been.calledOnce')
+ cy.get('@getDatasetLinkedCollections').should('have.callCount', 2)
+ cy.findByRole('button', { name: 'Unlink Dataset' }).should('not.exist')
+ })
+})
diff --git a/tests/component/sections/shared/template-metadata-form/TemplateMetadataForm.spec.tsx b/tests/component/sections/shared/template-metadata-form/TemplateMetadataForm.spec.tsx
index ff005d060..8cf49d592 100644
--- a/tests/component/sections/shared/template-metadata-form/TemplateMetadataForm.spec.tsx
+++ b/tests/component/sections/shared/template-metadata-form/TemplateMetadataForm.spec.tsx
@@ -1,10 +1,11 @@
-import { createTemplate } from '@iqss/dataverse-client-javascript'
import { useLocation } from 'react-router-dom'
import { TemplateMetadataForm } from '@/sections/shared/form/TemplateMetadataForm/TemplateMetadataForm'
import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository'
import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
import { TypeMetadataFieldOptions } from '@/metadata-block-info/domain/models/MetadataBlockInfo'
+import { RouteWithParams, TemplateEditMode } from '@/sections/Route.enum'
import { MetadataBlockInfoMother } from '../../../metadata-block-info/domain/models/MetadataBlockInfoMother'
+import { TemplateMother } from '../../templates/TemplateMother'
const metadataBlockInfoRepository: MetadataBlockInfoRepository = {} as MetadataBlockInfoRepository
const templateRepository: TemplateRepository = {} as TemplateRepository
@@ -13,7 +14,13 @@ const metadataBlocksInfo = MetadataBlockInfoMother.getAllBlocks()
describe('TemplateMetadataForm', () => {
const LocationDisplay = () => {
const location = useLocation()
- return {location.pathname}
+ return (
+
+
{location.pathname}
+
{location.search}
+
{JSON.stringify(location.state)}
+
+ )
}
beforeEach(() => {
@@ -25,12 +32,29 @@ describe('TemplateMetadataForm', () => {
cy.customMount(
<>
- >
+ >,
+ [RouteWithParams.TEMPLATES_CREATE('root')]
+ )
+
+ const mountEditTemplateMetadataForm = (template = TemplateMother.create({ id: 7 })) =>
+ cy.customMount(
+ <>
+
+
+ >,
+ [RouteWithParams.TEMPLATES_EDIT('root', template.id, TemplateEditMode.METADATA)]
)
const fillRequiredTemplateFields = () => {
@@ -125,7 +149,7 @@ describe('TemplateMetadataForm', () => {
})
it('should not enforce required validation when creating a template', () => {
- const executeStub = cy.stub(createTemplate, 'execute').resolves()
+ templateRepository.createTemplate = cy.stub().resolves()
mountTemplateMetadataForm()
@@ -137,8 +161,6 @@ describe('TemplateMetadataForm', () => {
cy.findByText('Point of Contact E-mail is required').should('not.exist')
cy.findByText('Description Text is required').should('not.exist')
cy.findByText('Subject is required').should('not.exist')
-
- executeStub.restore()
})
it('should show correct errors when filling inputs with invalid formats', () => {
@@ -172,7 +194,8 @@ describe('TemplateMetadataForm', () => {
})
it('should save custom instructions for template fields', () => {
- const executeStub = cy.stub(createTemplate, 'execute').resolves()
+ const createStub = cy.stub().resolves()
+ templateRepository.createTemplate = createStub
mountTemplateMetadataForm()
@@ -190,7 +213,7 @@ describe('TemplateMetadataForm', () => {
cy.findByTestId('custom-instructions-toggle-authorName').should('not.exist')
cy.findByRole('button', { name: 'Save + Add Terms' }).click()
- cy.wrap(executeStub).should('have.been.calledOnce')
+ cy.wrap(createStub).should('have.been.calledOnce')
})
it('should discard custom instructions when canceling edit', () => {
@@ -219,7 +242,8 @@ describe('TemplateMetadataForm', () => {
})
it('removes cleared custom instructions from the submit payload', () => {
- const executeStub = cy.stub(createTemplate, 'execute').resolves()
+ const createStub = cy.stub().resolves()
+ templateRepository.createTemplate = createStub
mountTemplateMetadataForm()
@@ -235,15 +259,18 @@ describe('TemplateMetadataForm', () => {
cy.findByRole('button', { name: 'Save + Add Terms' }).click()
- cy.wrap(executeStub).should('have.been.calledOnce')
- cy.wrap(executeStub).then((stub) => {
- const payload = stub.getCall(0).args[0] as { instructions?: unknown[] }
+ cy.wrap(createStub).should('have.been.calledOnce')
+ cy.wrap(createStub).then((stub) => {
+ const payload = (stub as unknown as sinon.SinonStub).getCall(0).args[0] as {
+ instructions?: unknown[]
+ }
expect(payload.instructions).to.equal(undefined)
})
})
it('removes only the cleared instruction when multiple exist', () => {
- const executeStub = cy.stub(createTemplate, 'execute').resolves()
+ const createStub = cy.stub().resolves()
+ templateRepository.createTemplate = createStub
mountTemplateMetadataForm()
@@ -263,9 +290,11 @@ describe('TemplateMetadataForm', () => {
cy.findByRole('button', { name: 'Save + Add Terms' }).click()
- cy.wrap(executeStub).should('have.been.calledOnce')
- cy.wrap(executeStub).then((stub) => {
- const payload = stub.getCall(0).args[0] as { instructions?: unknown[] }
+ cy.wrap(createStub).should('have.been.calledOnce')
+ cy.wrap(createStub).then((stub) => {
+ const payload = (stub as unknown as sinon.SinonStub).getCall(0).args[0] as {
+ instructions?: { instructionField: string }[]
+ }
expect(payload.instructions).to.have.length(1)
expect(payload.instructions?.[0]).to.have.property('instructionField', 'author')
})
@@ -280,4 +309,99 @@ describe('TemplateMetadataForm', () => {
cy.findByLabelText(/Template Name/).type('Valid Template Name')
cy.findByText('Please add in a name for the dataset template.').should('not.exist')
})
+
+ it('pre-fills existing custom instructions in edit mode', () => {
+ const template = TemplateMother.create({
+ id: 7,
+ name: 'Existing Template',
+ instructions: [
+ {
+ instructionField: 'title',
+ instructionText: 'Use the official dataset title'
+ },
+ {
+ instructionField: 'author',
+ instructionText: 'List all contributing authors'
+ }
+ ]
+ })
+
+ mountEditTemplateMetadataForm(template)
+
+ cy.findByTestId('custom-instructions-toggle-title')
+ .should('contain.text', 'Use the official dataset title')
+ .click()
+ cy.findByTestId('custom-instructions-input-title').should(
+ 'have.value',
+ 'Use the official dataset title'
+ )
+
+ cy.findByTestId('custom-instructions-toggle-author')
+ .should('contain.text', 'List all contributing authors')
+ .click()
+ cy.findByTestId('custom-instructions-input-author').should(
+ 'have.value',
+ 'List all contributing authors'
+ )
+ })
+
+ it('navigates to edit template terms after creating the template', () => {
+ templateRepository.createTemplate = cy.stub().resolves()
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([
+ TemplateMother.create({ id: 99, name: 'Different Template' }),
+ TemplateMother.create({ id: 42, name: ' test template ' })
+ ])
+
+ mountTemplateMetadataForm()
+
+ cy.findByLabelText(/Template Name/).type(' Test Template ')
+ cy.findByRole('button', { name: 'Save + Add Terms' }).click()
+
+ cy.findByTestId('location-display').should('have.text', '/templates/edit')
+ cy.findByTestId('location-search-display').should(
+ 'have.text',
+ `?id=42&ownerId=root&editMode=${TemplateEditMode.LICENSE}`
+ )
+ cy.findByTestId('location-state-display').should(
+ 'have.text',
+ JSON.stringify({ fromCreateTemplate: true })
+ )
+ })
+
+ it('does not navigate to edit terms when the created template cannot be resolved after submit', () => {
+ templateRepository.createTemplate = cy.stub().resolves()
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([TemplateMother.create({ id: 99, name: 'Different Template' })])
+
+ mountTemplateMetadataForm()
+
+ cy.findByLabelText(/Template Name/).type('Test Template')
+ cy.findByRole('button', { name: 'Save + Add Terms' }).click()
+
+ cy.findByTestId('location-display').should(
+ 'have.text',
+ RouteWithParams.TEMPLATES_CREATE('root')
+ )
+ cy.findByTestId('location-search-display').should('have.text', '')
+ })
+
+ it('does not refresh templates or navigate when create submit fails', () => {
+ templateRepository.getTemplatesByCollectionId = cy.stub().resolves([])
+ templateRepository.createTemplate = cy.stub().rejects(new Error('Failed to save'))
+
+ mountTemplateMetadataForm()
+
+ cy.findByLabelText(/Template Name/).type('Test Template')
+ cy.findByRole('button', { name: 'Save + Add Terms' }).click()
+
+ cy.wrap(templateRepository.getTemplatesByCollectionId).should('have.been.calledOnce')
+ cy.findByTestId('location-display').should(
+ 'have.text',
+ RouteWithParams.TEMPLATES_CREATE('root')
+ )
+ cy.findByRole('alert').should('contain.text', 'Failed to save')
+ })
})
diff --git a/tests/component/sections/templates/DatasetTemplates.spec.tsx b/tests/component/sections/templates/DatasetTemplates.spec.tsx
index 47b9befbd..72cf0b9a2 100644
--- a/tests/component/sections/templates/DatasetTemplates.spec.tsx
+++ b/tests/component/sections/templates/DatasetTemplates.spec.tsx
@@ -2,6 +2,7 @@ import { DatasetTemplates } from '../../../../src/sections/templates/DatasetTemp
import { CollectionRepository } from '../../../../src/collection/domain/repositories/CollectionRepository'
import { TemplateRepository } from '../../../../src/templates/domain/repositories/TemplateRepository'
import { MetadataBlockInfoRepository } from '../../../../src/metadata-block-info/domain/repositories/MetadataBlockInfoRepository'
+import { TemplateEditMode } from '../../../../src/sections/Route.enum'
import { ReadError, WriteError } from '@iqss/dataverse-client-javascript'
import { CollectionMother } from '../../collection/domain/models/CollectionMother'
import { TemplateMother } from './TemplateMother'
@@ -9,6 +10,8 @@ import { NotImplementedModalProvider } from '../../../../src/sections/not-implem
import { MetadataBlockInfoMother } from '../../metadata-block-info/domain/models/MetadataBlockInfoMother'
import { CitationMetadataBlockInfoMother } from '../../metadata-block-info/domain/models/CitationMetadataBlockInfoMother'
import { UpwardHierarchyNodeMother } from '../../shared/hierarchy/domain/models/UpwardHierarchyNodeMother'
+import { useLocation } from 'react-router-dom'
+import { RouterInitialEntry } from '../../../support/commands'
const collectionRepository: CollectionRepository = {} as CollectionRepository
const templateRepository: TemplateRepository = {} as TemplateRepository
@@ -35,6 +38,17 @@ const template = TemplateMother.create({
isDefault: false
})
describe('Dataset Templates', () => {
+ const LocationDisplay = () => {
+ const location = useLocation()
+ return (
+ <>
+ {location.pathname}
+ {location.search}
+ {JSON.stringify(location.state)}
+ >
+ )
+ }
+
beforeEach(() => {
collectionRepository.getById = cy.stub().resolves(collection)
collectionRepository.getUserPermissions = cy.stub().resolves({
@@ -49,16 +63,20 @@ describe('Dataset Templates', () => {
templateRepository.getTemplatesByCollectionId = cy.stub().resolves([])
})
- const mountDatasetTemplates = () =>
+ const mountDatasetTemplates = (initialEntries: RouterInitialEntry[] = ['/root/templates']) =>
cy.customMount(
-
-
-
+ <>
+
+
+
+
+ >,
+ initialEntries
)
it('shows not found when the collection does not exist', () => {
@@ -117,7 +135,31 @@ describe('Dataset Templates', () => {
})
})
- it('shows Default as disabled and hides Make Default for the default template', () => {
+ it('shows the edit success toast and clears location state after returning from edit', () => {
+ templateRepository.getTemplatesByCollectionId = cy.stub().resolves([template])
+
+ mountDatasetTemplates([
+ {
+ pathname: '/root/templates',
+ state: { fromEditTemplate: true }
+ }
+ ])
+
+ cy.findByRole('alert').should('contain.text', 'Template updated.')
+ cy.findByTestId('location-display').should('have.text', '/root/templates')
+ cy.findByTestId('location-state-display').should('have.text', 'null')
+ })
+
+ it('navigates to create template when clicking the create button', () => {
+ templateRepository.getTemplatesByCollectionId = cy.stub().resolves([template])
+
+ mountDatasetTemplates()
+
+ cy.findByRole('button', { name: 'Create Dataset Template' }).click()
+ cy.findByTestId('location-display').should('have.text', '/create')
+ })
+
+ it('shows Default and hides Make Default for the default template', () => {
const [templateDefault, templateOther] = TemplateMother.createTemplates([
{ id: 1, name: 'Template Default', isDefault: true },
{ id: 2, name: 'Template Other', isDefault: false }
@@ -131,11 +173,233 @@ describe('Dataset Templates', () => {
cy.findByText('Template Default')
.closest('tr')
.within(() => {
- cy.findByRole('button', { name: 'Default' }).should('be.disabled')
+ cy.findByRole('button', { name: 'Default' }).should('not.be.disabled')
cy.findByRole('button', { name: 'Make Default' }).should('not.exist')
})
})
+ describe('Set/Unset Default Template', () => {
+ it('toggles a non-default template to default and updates buttons without refetching', () => {
+ const [templateDefault, templateOther] = TemplateMother.createTemplates([
+ { id: 1, name: 'Template Default', isDefault: true },
+ { id: 2, name: 'Template Other', isDefault: false }
+ ])
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([templateDefault, templateOther])
+ templateRepository.setTemplateAsDefault = cy.stub().resolves()
+
+ mountDatasetTemplates()
+
+ cy.findByText('Template Other')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).click()
+ })
+
+ cy.findByText(
+ /The template has been selected as the default template for this dataverse./i
+ ).should('exist')
+
+ cy.findByText('Template Other')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Default' }).should('not.be.disabled')
+ cy.findByRole('button', { name: 'Make Default' }).should('not.exist')
+ })
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).should('exist')
+ cy.findByRole('button', { name: 'Default' }).should('not.exist')
+ })
+
+ cy.wrap(templateRepository.getTemplatesByCollectionId).should('have.been.calledOnce')
+ })
+
+ it('disables default action buttons while setting a template as default', () => {
+ const [templateDefault, templateOther] = TemplateMother.createTemplates([
+ { id: 1, name: 'Template Default', isDefault: true },
+ { id: 2, name: 'Template Other', isDefault: false }
+ ])
+ let resolveSetTemplateAsDefault: () => void
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([templateDefault, templateOther])
+ templateRepository.setTemplateAsDefault = cy.stub().callsFake(
+ () =>
+ new Cypress.Promise((resolve) => {
+ resolveSetTemplateAsDefault = resolve
+ })
+ )
+
+ mountDatasetTemplates()
+
+ cy.findByText('Template Other')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).click()
+ cy.findByRole('button', { name: 'Make Default' }).should('be.disabled')
+ })
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Default' }).should('be.disabled')
+ })
+
+ cy.then(() => {
+ resolveSetTemplateAsDefault()
+ })
+
+ cy.findByText(
+ /The template has been selected as the default template for this dataverse./i
+ ).should('exist')
+ })
+
+ it('unsets a default template and updates buttons without refetching', () => {
+ const [templateDefault, templateOther] = TemplateMother.createTemplates([
+ { id: 1, name: 'Template Default', isDefault: true },
+ { id: 2, name: 'Template Other', isDefault: false }
+ ])
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([templateDefault, templateOther])
+ templateRepository.unsetTemplateAsDefault = cy.stub().resolves()
+
+ mountDatasetTemplates()
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Default' }).click({ force: true })
+ })
+
+ cy.findByText(
+ /The template has been removed as the default template for this dataverse./i
+ ).should('exist')
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).should('exist')
+ })
+
+ cy.findByText('Template Other')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).should('exist')
+ })
+
+ cy.wrap(templateRepository.getTemplatesByCollectionId).should('have.been.calledOnce')
+ })
+
+ it('disables the Default button while unsetting the default template', () => {
+ const [templateDefault, templateOther] = TemplateMother.createTemplates([
+ { id: 1, name: 'Template Default', isDefault: true },
+ { id: 2, name: 'Template Other', isDefault: false }
+ ])
+ let resolveUnsetTemplateAsDefault: () => void
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([templateDefault, templateOther])
+ templateRepository.unsetTemplateAsDefault = cy.stub().callsFake(
+ () =>
+ new Cypress.Promise((resolve) => {
+ resolveUnsetTemplateAsDefault = resolve
+ })
+ )
+
+ mountDatasetTemplates()
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Default' }).click()
+ cy.findByRole('button', { name: 'Default' }).should('be.disabled')
+ })
+
+ cy.findByText('Template Other')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).should('be.disabled')
+ })
+
+ cy.then(() => {
+ resolveUnsetTemplateAsDefault()
+ })
+
+ cy.findByText(
+ /The template has been removed as the default template for this dataverse./i
+ ).should('exist')
+ })
+
+ it('shows an error toast when setting default fails', () => {
+ const [templateDefault, templateOther] = TemplateMother.createTemplates([
+ { id: 1, name: 'Template Default', isDefault: true },
+ { id: 2, name: 'Template Other', isDefault: false }
+ ])
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([templateDefault, templateOther])
+ templateRepository.setTemplateAsDefault = cy
+ .stub()
+ .rejects(new WriteError('Set default failed'))
+
+ mountDatasetTemplates()
+
+ cy.findByText('Template Other')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).click()
+ })
+
+ cy.findByText(/Set default failed/i).should('exist')
+
+ cy.findByText('Template Other')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).should('exist')
+ })
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Default' }).should('not.be.disabled')
+ })
+ })
+
+ it('shows an error toast when unsetting default fails', () => {
+ const [templateDefault, templateOther] = TemplateMother.createTemplates([
+ { id: 1, name: 'Template Default', isDefault: true },
+ { id: 2, name: 'Template Other', isDefault: false }
+ ])
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([templateDefault, templateOther])
+ templateRepository.unsetTemplateAsDefault = cy.stub().rejects(new Error('Unset failed'))
+
+ mountDatasetTemplates()
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Default' }).click({ force: true })
+ })
+
+ cy.findByText(/Something went wrong removing the default template. Try again later./i).should(
+ 'exist'
+ )
+
+ cy.findByText('Template Default')
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Default' }).should('not.be.disabled')
+ })
+ })
+ })
+
it('hides the edit dropdown when the user cannot edit the collection', () => {
collectionRepository.getUserPermissions = cy.stub().resolves({
canAddCollection: false,
@@ -191,6 +455,10 @@ describe('Dataset Templates', () => {
mountDatasetTemplates()
+ cy.findByRole('navigation', { name: 'breadcrumb' }).within(() => {
+ cy.findByRole('link', { name: 'Root' }).should('have.attr', 'href', '/collections')
+ cy.findByText('Dataset Templates').should('exist')
+ })
cy.findByLabelText('Include Templates from Root').should('not.exist')
})
@@ -319,6 +587,7 @@ describe('Dataset Templates', () => {
})
const rootTemplate = TemplateMother.create({
+ id: 1,
name: 'Template Root',
collectionAlias: 'root',
usageCount: 0
@@ -333,6 +602,28 @@ describe('Dataset Templates', () => {
cy.findByRole('button', { name: 'Delete' }).should('exist').and('not.be.disabled')
})
+ it('navigates to edit template metadata from the edit dropdown', () => {
+ cy.findByRole('button', { name: 'Edit Template' }).click()
+ cy.findByText('Metadata').click()
+
+ cy.findByTestId('location-display').should('have.text', '/templates/edit')
+ cy.findByTestId('location-search-display').should(
+ 'have.text',
+ `?id=1&ownerId=root&editMode=${TemplateEditMode.METADATA}`
+ )
+ })
+
+ it('navigates to edit template terms from the edit dropdown', () => {
+ cy.findByRole('button', { name: 'Edit Template' }).click()
+ cy.findByText('Terms').click()
+
+ cy.findByTestId('location-display').should('have.text', '/templates/edit')
+ cy.findByTestId('location-search-display').should(
+ 'have.text',
+ `?id=1&ownerId=root&editMode=${TemplateEditMode.LICENSE}`
+ )
+ })
+
it('disables delete when the template has been used in a dataset', () => {
const usedTemplate = TemplateMother.create({
name: 'Used Template',
@@ -434,6 +725,22 @@ describe('Dataset Templates', () => {
name: 'Template Copy',
collectionAlias: 'root',
isDefault: false,
+ termsOfUse: {
+ customTerms: {
+ termsOfUse: 'Existing custom terms',
+ confidentialityDeclaration: 'Confidentiality',
+ specialPermissions: 'Permissions',
+ restrictions: 'Restrictions',
+ citationRequirements: 'Citations',
+ depositorRequirements: 'Depositor requirements',
+ conditions: 'Conditions',
+ disclaimer: 'Disclaimer'
+ },
+ termsOfAccess: {
+ fileAccessRequest: true,
+ termsOfAccessForRestrictedFiles: 'Access is restricted.'
+ }
+ },
datasetMetadataBlocks: [
{
name: 'citation',
@@ -449,10 +756,24 @@ describe('Dataset Templates', () => {
}
]
})
+ const copiedTemplate = TemplateMother.create({
+ id: 11,
+ name: 'copy Template Copy',
+ collectionAlias: 'root'
+ })
- templateRepository.getTemplatesByCollectionId = cy.stub().resolves([templateWithMetadata])
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .onFirstCall()
+ .resolves([templateWithMetadata])
+ .onSecondCall()
+ .resolves([templateWithMetadata, copiedTemplate])
+ .onThirdCall()
+ .resolves([templateWithMetadata, copiedTemplate])
templateRepository.getTemplate = cy.stub().resolves(templateWithMetadata)
templateRepository.createTemplate = cy.stub().resolves()
+ templateRepository.updateTemplateLicenseTerms = cy.stub().resolves()
+ templateRepository.updateTemplateTermsOfAccess = cy.stub().resolves()
metadataBlockInfoRepository.getByCollectionId = cy
.stub()
.resolves([CitationMetadataBlockInfoMother.get()])
@@ -479,7 +800,68 @@ describe('Dataset Templates', () => {
},
'root'
)
- cy.wrap(templateRepository.getTemplatesByCollectionId).should('have.been.calledTwice')
+ cy.wrap(templateRepository.updateTemplateLicenseTerms).should('have.been.calledWith', 11, {
+ customTerms: templateWithMetadata.termsOfUse.customTerms
+ })
+ cy.wrap(templateRepository.updateTemplateTermsOfAccess).should(
+ 'have.been.calledWith',
+ 11,
+ templateWithMetadata.termsOfUse.termsOfAccess
+ )
+ cy.wrap(templateRepository.getTemplatesByCollectionId).should('have.been.calledThrice')
+ })
+
+ it('copies a template standard license', () => {
+ const templateWithLicense = TemplateMother.create({
+ id: 10,
+ name: 'Template Copy',
+ collectionAlias: 'root',
+ isDefault: false,
+ license: {
+ id: 2,
+ name: 'CC BY 4.0',
+ uri: 'http://creativecommons.org/licenses/by/4.0',
+ iconUri: '',
+ active: true,
+ isDefault: false,
+ sortOrder: 2
+ },
+ termsOfUse: {
+ termsOfAccess: {
+ fileAccessRequest: false
+ }
+ }
+ })
+ const copiedTemplate = TemplateMother.create({
+ id: 11,
+ name: 'copy Template Copy',
+ collectionAlias: 'root'
+ })
+
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .onFirstCall()
+ .resolves([templateWithLicense])
+ .onSecondCall()
+ .resolves([templateWithLicense, copiedTemplate])
+ .onThirdCall()
+ .resolves([templateWithLicense, copiedTemplate])
+ templateRepository.getTemplate = cy.stub().resolves(templateWithLicense)
+ templateRepository.createTemplate = cy.stub().resolves()
+ templateRepository.updateTemplateLicenseTerms = cy.stub().resolves()
+ templateRepository.updateTemplateTermsOfAccess = cy.stub().resolves()
+ metadataBlockInfoRepository.getByCollectionId = cy
+ .stub()
+ .resolves([CitationMetadataBlockInfoMother.get()])
+
+ mountDatasetTemplates()
+
+ cy.findByRole('button', { name: 'Copy' }).click({ force: true })
+
+ cy.findByText('Template copied.').should('exist')
+ cy.wrap(templateRepository.updateTemplateLicenseTerms).should('have.been.calledWith', 11, {
+ name: 'CC BY 4.0'
+ })
})
it('shows an error toast when fetching the template fails', () => {
diff --git a/tests/component/sections/templates/create-template/CreateTemplate.spec.tsx b/tests/component/sections/templates/create-template/CreateTemplate.spec.tsx
index 5e5116d18..28cc191c3 100644
--- a/tests/component/sections/templates/create-template/CreateTemplate.spec.tsx
+++ b/tests/component/sections/templates/create-template/CreateTemplate.spec.tsx
@@ -4,12 +4,31 @@ import { MetadataBlockInfoRepository } from '../../../../../src/metadata-block-i
import { TemplateRepository } from '../../../../../src/templates/domain/repositories/TemplateRepository'
import { CollectionMother } from '../../../collection/domain/models/CollectionMother'
import { MetadataBlockInfoMother } from '../../../metadata-block-info/domain/models/MetadataBlockInfoMother'
+import { UpwardHierarchyNodeMother } from '../../../shared/hierarchy/domain/models/UpwardHierarchyNodeMother'
const collectionRepository: CollectionRepository = {} as CollectionRepository
const metadataBlockInfoRepository: MetadataBlockInfoRepository = {} as MetadataBlockInfoRepository
const templateRepository: TemplateRepository = {} as TemplateRepository
-const collection = CollectionMother.create({ name: 'Root', id: 'root' })
+const root = UpwardHierarchyNodeMother.createCollection({ name: 'Root', id: 'root' })
+const subcollection = UpwardHierarchyNodeMother.createCollection({
+ name: 'Subcollection',
+ id: 'subcollection',
+ parent: root
+})
+const dataset = UpwardHierarchyNodeMother.createDataset({
+ name: 'Dataset',
+ id: 'dataset-id',
+ persistentId: 'doi:10.5072/FK2/ABC123',
+ version: 'DRAFT',
+ parent: subcollection
+})
+
+const collection = CollectionMother.create({
+ name: 'Current Collection',
+ id: 'root',
+ hierarchy: dataset
+})
const collectionMetadataBlocksInfo =
MetadataBlockInfoMother.getByCollectionIdDisplayedOnCreateTrue()
@@ -58,6 +77,28 @@ describe('Create Template', () => {
cy.findByTestId('custom-instructions-toggle-title').should('exist')
})
+ it('renders the breadcrumb trail, heading, and create form', () => {
+ mountCreateTemplate()
+
+ cy.findByRole('link', { name: 'Root' }).should('have.attr', 'href', '/collections')
+ cy.findByRole('link', { name: 'Subcollection' }).should(
+ 'have.attr',
+ 'href',
+ '/collections/subcollection'
+ )
+ cy.findByRole('link', { name: 'Dataset' }).should(
+ 'have.attr',
+ 'href',
+ '/datasets?persistentId=doi:10.5072/FK2/ABC123&version=DRAFT'
+ )
+ cy.findByRole('navigation', { name: 'breadcrumb' }).within(() => {
+ cy.findByText('Create Dataset Template').should('exist')
+ })
+ cy.findByRole('heading', { name: 'Create Dataset Template' }).should('exist')
+ cy.findByLabelText(/Template Name/).should('exist')
+ cy.findByRole('button', { name: 'Save + Add Terms' }).should('exist')
+ })
+
it('should render asterisks tips under template name', () => {
mountCreateTemplate()
diff --git a/tests/component/sections/templates/create-template/useSubmitTemplate.spec.tsx b/tests/component/sections/templates/create-template/useSubmitTemplate.spec.tsx
index 3f8c657e3..9231aa6a0 100644
--- a/tests/component/sections/templates/create-template/useSubmitTemplate.spec.tsx
+++ b/tests/component/sections/templates/create-template/useSubmitTemplate.spec.tsx
@@ -2,8 +2,10 @@ import { act, renderHook } from '@testing-library/react'
import { I18nextProvider } from 'react-i18next'
import i18next, { i18n as I18nInstance } from 'i18next'
import { initReactI18next } from 'react-i18next'
-import { createTemplate, WriteError } from '@iqss/dataverse-client-javascript'
+import { WriteError } from '@iqss/dataverse-client-javascript'
import { TemplateInfo, TemplateInstructionInfo } from '@/templates/domain/models/TemplateInfo'
+import { UpdateTemplateMetadataInfo } from '@/templates/domain/models/UpdateTemplateMetadataInfo'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
import {
SubmissionStatus,
useSubmitTemplate
@@ -25,6 +27,12 @@ const templatePayloadWithInstructions: TemplateInfo = {
instructions: templateInstructions
}
+const updatePayload: UpdateTemplateMetadataInfo = {
+ name: 'Renamed Template',
+ fields: [],
+ instructions: []
+}
+
const createI18n = (): I18nInstance => {
const instance = i18next.createInstance()
void instance.use(initReactI18next).init({
@@ -40,6 +48,11 @@ const createI18n = (): I18nInstance => {
errors: {
saveFailed: 'Save failed.'
}
+ },
+ editTemplate: {
+ errors: {
+ saveMetadataFailed: 'Update failed.'
+ }
}
}
}
@@ -49,6 +62,8 @@ const createI18n = (): I18nInstance => {
return instance
}
+const createRepository = (): TemplateRepository => ({} as TemplateRepository)
+
describe('useSubmitTemplate', () => {
let i18n: I18nInstance
@@ -56,78 +71,180 @@ describe('useSubmitTemplate', () => {
i18n = createI18n()
})
- it('should submit the template successfully', async () => {
- const executeStub = cy.stub(createTemplate, 'execute').resolves()
+ describe('create mode', () => {
+ it('submits the template successfully', async () => {
+ const repository = createRepository()
+ repository.createTemplate = cy.stub().resolves()
+
+ const { result } = renderHook(
+ () =>
+ useSubmitTemplate({
+ mode: 'create',
+ templateRepository: repository,
+ collectionId: 'root'
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
- const { result } = renderHook(() => useSubmitTemplate('root'), {
- wrapper: ({ children }) => {children}
- })
+ expect(result.current.submissionStatus).to.equal(SubmissionStatus.NotSubmitted)
- expect(result.current.submissionStatus).to.equal(SubmissionStatus.NotSubmitted)
- expect(result.current.submitError).to.equal(null)
+ await act(async () => {
+ const didSubmit = await result.current.submitTemplate(templatePayload)
+ expect(didSubmit).to.equal(true)
+ })
- await act(async () => {
- const didSubmit = await result.current.submitTemplate(templatePayload)
- expect(didSubmit).to.equal(true)
+ expect(result.current.submissionStatus).to.equal(SubmissionStatus.SubmitComplete)
+ expect(result.current.submitError).to.equal(null)
+ expect(repository.createTemplate).to.have.been.calledWith(templatePayload, 'root')
})
- expect(result.current.submissionStatus).to.equal(SubmissionStatus.SubmitComplete)
- expect(result.current.submitError).to.equal(null)
- cy.wrap(executeStub).should('have.been.calledWith', templatePayload, 'root')
-
- executeStub.restore()
- })
+ it('submits the template with instructions', async () => {
+ const repository = createRepository()
+ repository.createTemplate = cy.stub().resolves()
+
+ const { result } = renderHook(
+ () =>
+ useSubmitTemplate({
+ mode: 'create',
+ templateRepository: repository,
+ collectionId: 'root'
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
- it('should handle WriteError responses', async () => {
- const executeStub = cy.stub(createTemplate, 'execute').rejects(new WriteError('Write error'))
+ await act(async () => {
+ await result.current.submitTemplate(templatePayloadWithInstructions)
+ })
- const { result } = renderHook(() => useSubmitTemplate('root'), {
- wrapper: ({ children }) => {children}
+ expect(repository.createTemplate).to.have.been.calledWith(
+ templatePayloadWithInstructions,
+ 'root'
+ )
})
- await act(async () => {
- const didSubmit = await result.current.submitTemplate(templatePayload)
- expect(didSubmit).to.equal(false)
+ it('surfaces a WriteError reason as the submit error', async () => {
+ const repository = createRepository()
+ repository.createTemplate = cy.stub().rejects(new WriteError('Write error'))
+
+ const { result } = renderHook(
+ () =>
+ useSubmitTemplate({
+ mode: 'create',
+ templateRepository: repository,
+ collectionId: 'root'
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
+
+ await act(async () => {
+ const didSubmit = await result.current.submitTemplate(templatePayload)
+ expect(didSubmit).to.equal(false)
+ })
+
+ expect(result.current.submissionStatus).to.equal(SubmissionStatus.Errored)
+ expect(result.current.submitError).to.equal('Write error')
})
- expect(result.current.submissionStatus).to.equal(SubmissionStatus.Errored)
- expect(result.current.submitError).to.equal('Write error')
+ it('falls back to a generic message for non-WriteError, non-Error rejections', async () => {
+ const repository = createRepository()
+ repository.createTemplate = cy.stub().rejects('Error')
+
+ const { result } = renderHook(
+ () =>
+ useSubmitTemplate({
+ mode: 'create',
+ templateRepository: repository,
+ collectionId: 'root'
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
+
+ await act(async () => {
+ await result.current.submitTemplate(templatePayload)
+ })
- executeStub.restore()
+ expect(result.current.submitError).to.equal('Save failed.')
+ })
})
- it('should handle non-WriteError responses with a default message', async () => {
- const executeStub = cy.stub(createTemplate, 'execute').rejects('Error')
+ describe('edit mode', () => {
+ it('updates the template metadata successfully', async () => {
+ const repository = createRepository()
+ repository.updateTemplateMetadata = cy.stub().resolves()
+
+ const { result } = renderHook(
+ () =>
+ useSubmitTemplate({
+ mode: 'edit',
+ templateRepository: repository,
+ templateId: 42
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
- const { result } = renderHook(() => useSubmitTemplate('root'), {
- wrapper: ({ children }) => {children}
- })
+ await act(async () => {
+ const didSubmit = await result.current.submitTemplate(updatePayload)
+ expect(didSubmit).to.equal(true)
+ })
- await act(async () => {
- const didSubmit = await result.current.submitTemplate(templatePayload)
- expect(didSubmit).to.equal(false)
+ expect(result.current.submissionStatus).to.equal(SubmissionStatus.SubmitComplete)
+ expect(repository.updateTemplateMetadata).to.have.been.calledWith(42, updatePayload, true)
})
- expect(result.current.submissionStatus).to.equal(SubmissionStatus.Errored)
- expect(result.current.submitError).to.equal('Save failed.')
-
- executeStub.restore()
- })
+ it('uses the edit-mode error message for non-WriteError exceptions without messages', async () => {
+ const repository = createRepository()
+ repository.updateTemplateMetadata = cy.stub().rejects('weird')
+
+ const { result } = renderHook(
+ () =>
+ useSubmitTemplate({
+ mode: 'edit',
+ templateRepository: repository,
+ templateId: 42
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
- it('should submit template with instructions', async () => {
- const executeStub = cy.stub(createTemplate, 'execute').resolves()
+ await act(async () => {
+ await result.current.submitTemplate(updatePayload)
+ })
- const { result } = renderHook(() => useSubmitTemplate('root'), {
- wrapper: ({ children }) => {children}
+ expect(result.current.submitError).to.equal('Update failed.')
})
- await act(async () => {
- const didSubmit = await result.current.submitTemplate(templatePayloadWithInstructions)
- expect(didSubmit).to.equal(true)
- })
+ it('passes through a regular Error message in edit mode', async () => {
+ const repository = createRepository()
+ repository.updateTemplateMetadata = cy.stub().rejects(new Error('Network down'))
+
+ const { result } = renderHook(
+ () =>
+ useSubmitTemplate({
+ mode: 'edit',
+ templateRepository: repository,
+ templateId: 42
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
- cy.wrap(executeStub).should('have.been.calledWith', templatePayloadWithInstructions, 'root')
+ await act(async () => {
+ await result.current.submitTemplate(updatePayload)
+ })
- executeStub.restore()
+ expect(result.current.submitError).to.equal('Network down')
+ })
})
})
diff --git a/tests/component/sections/templates/edit-template-metadata/EditTemplateMetadata.spec.tsx b/tests/component/sections/templates/edit-template-metadata/EditTemplateMetadata.spec.tsx
new file mode 100644
index 000000000..d59744956
--- /dev/null
+++ b/tests/component/sections/templates/edit-template-metadata/EditTemplateMetadata.spec.tsx
@@ -0,0 +1,134 @@
+import { ReadError } from '@iqss/dataverse-client-javascript'
+import { EditTemplateMetadata } from '@/sections/templates/edit-template-metadata/EditTemplateMetadata'
+import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository'
+import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { CollectionMother } from '../../../collection/domain/models/CollectionMother'
+import { MetadataBlockInfoMother } from '../../../metadata-block-info/domain/models/MetadataBlockInfoMother'
+import { TemplateMother } from '../TemplateMother'
+
+const collectionRepository: CollectionRepository = {} as CollectionRepository
+const metadataBlockInfoRepository: MetadataBlockInfoRepository = {} as MetadataBlockInfoRepository
+const templateRepository: TemplateRepository = {} as TemplateRepository
+
+const collection = CollectionMother.create({ name: 'Root', id: 'root' })
+const collectionMetadataBlocksInfo =
+ MetadataBlockInfoMother.getByCollectionIdDisplayedOnCreateTrue()
+
+const template = TemplateMother.create({ id: 1, name: 'Existing Template' })
+
+describe('EditTemplateMetadata', () => {
+ beforeEach(() => {
+ collectionRepository.getById = cy.stub().resolves(collection)
+ metadataBlockInfoRepository.getByCollectionId = cy.stub().resolves(collectionMetadataBlocksInfo)
+ templateRepository.getTemplate = cy.stub().resolves(template)
+ templateRepository.getTemplatesByCollectionId = cy.stub().resolves([template])
+ })
+
+ const mountEditTemplateMetadata = () =>
+ cy.customMount(
+
+ )
+
+ it('shows page not found when the owner collection does not exist', () => {
+ collectionRepository.getById = cy.stub().resolves(null)
+
+ mountEditTemplateMetadata()
+
+ cy.findByTestId('not-found-page').should('exist')
+ })
+
+ it('shows the loading skeleton while loading', () => {
+ const delayedTime = 200
+ templateRepository.getTemplate = cy.stub().callsFake(() => {
+ return Cypress.Promise.delay(delayedTime).then(() => template)
+ })
+
+ mountEditTemplateMetadata()
+
+ cy.clock()
+ cy.findByTestId('edit-template-metadata-skeleton').should('exist')
+
+ cy.tick(delayedTime)
+ cy.findByTestId('edit-template-metadata-skeleton').should('not.exist')
+ })
+
+ it('renders the page title and breadcrumb', () => {
+ mountEditTemplateMetadata()
+
+ cy.findByRole('heading', { name: /Edit Template Metadata/i }).should('exist')
+ cy.findByRole('link', { name: /Dataset Templates/i }).should('exist')
+ })
+
+ it('pre-fills the template name with the existing value', () => {
+ mountEditTemplateMetadata()
+
+ cy.findByLabelText(/Template Name/).should('have.value', 'Existing Template')
+ })
+
+ it('renders Save Changes and Cancel buttons', () => {
+ mountEditTemplateMetadata()
+
+ cy.findByRole('button', { name: 'Save Changes' }).should('exist')
+ cy.findByRole('button', { name: 'Cancel' }).should('exist')
+ })
+
+ it('shows an error message when the template fails to load', () => {
+ templateRepository.getTemplate = cy.stub().rejects(new ReadError('Template fetch boom'))
+
+ mountEditTemplateMetadata()
+
+ cy.findByText(/Template fetch boom/i).should('exist')
+ })
+
+ it('shows the fallback loading-template error when no template is returned', () => {
+ templateRepository.getTemplate = cy.stub().resolves(null)
+
+ mountEditTemplateMetadata()
+
+ cy.findByRole('alert').should(
+ 'contain.text',
+ 'Something went wrong loading the template. Try again later.'
+ )
+ cy.findByLabelText(/Template Name/).should('not.exist')
+ })
+
+ it('calls updateTemplateMetadata with the edited name', () => {
+ templateRepository.updateTemplateMetadata = cy.stub().resolves()
+
+ mountEditTemplateMetadata()
+
+ cy.findByLabelText(/Template Name/)
+ .clear()
+ .type('Renamed Template')
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.wrap(templateRepository.updateTemplateMetadata).should('have.been.calledOnce')
+ cy.wrap(templateRepository.updateTemplateMetadata).then((stub) => {
+ const args = (stub as unknown as sinon.SinonStub).getCall(0).args as [
+ number,
+ { name: string }
+ ]
+ expect(args[0]).to.equal(1)
+ expect(args[1].name).to.equal('Renamed Template')
+ })
+ })
+
+ it('shows the validation error when the template name is cleared', () => {
+ mountEditTemplateMetadata()
+
+ cy.findByLabelText(/Template Name/).clear()
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.findAllByText(/Please add in a name for the dataset template./i).should(
+ 'have.length.at.least',
+ 1
+ )
+ })
+})
diff --git a/tests/component/sections/templates/edit-template-terms/EditTemplateLicenseTerms.spec.tsx b/tests/component/sections/templates/edit-template-terms/EditTemplateLicenseTerms.spec.tsx
new file mode 100644
index 000000000..01bcedaec
--- /dev/null
+++ b/tests/component/sections/templates/edit-template-terms/EditTemplateLicenseTerms.spec.tsx
@@ -0,0 +1,195 @@
+import { LicenseRepository } from '@/licenses/domain/repositories/LicenseRepository'
+import { License } from '@/licenses/domain/models/License'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { EditTemplateLicenseTerms } from '@/sections/templates/edit-template-terms/EditTemplateLicenseTerms'
+import { TemplateMother } from '../TemplateMother'
+
+const licenseRepository: LicenseRepository = {} as LicenseRepository
+const templateRepository: TemplateRepository = {} as TemplateRepository
+
+const mockLicenses: License[] = [
+ {
+ id: 1,
+ name: 'CC0 1.0',
+ uri: 'http://creativecommons.org/publicdomain/zero/1.0',
+ iconUri: '',
+ active: true,
+ isDefault: true,
+ sortOrder: 0
+ },
+ {
+ id: 2,
+ name: 'CC BY 4.0',
+ uri: 'http://creativecommons.org/licenses/by/4.0',
+ iconUri: '',
+ active: true,
+ isDefault: false,
+ sortOrder: 2
+ }
+]
+
+describe('EditTemplateLicenseTerms', () => {
+ beforeEach(() => {
+ licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses)
+ })
+
+ const mountEditTemplateLicenseTerms = (
+ onSuccess: () => void = cy.stub().as('onSuccess'),
+ template = TemplateMother.create({
+ id: 5,
+ name: 'Tpl',
+ license: mockLicenses[0],
+ termsOfUse: { termsOfAccess: { fileAccessRequest: false } }
+ })
+ ) =>
+ cy.customMount(
+
+ )
+
+ it('renders the license selector and Save Changes button', () => {
+ mountEditTemplateLicenseTerms()
+
+ cy.findByRole('button', { name: 'Save Changes' }).should('exist')
+ })
+
+ it('submits a license update with the selected license name', () => {
+ templateRepository.updateTemplateLicenseTerms = cy.stub().resolves()
+ const onSuccess = cy.stub()
+
+ mountEditTemplateLicenseTerms(onSuccess)
+
+ // Wait for licenses to load and form to revalidate
+ cy.findByRole('option', { name: 'CC0 1.0' }).should('exist')
+ cy.findByRole('button', { name: 'Save Changes' }).should('not.be.disabled').click()
+
+ cy.wrap(templateRepository.updateTemplateLicenseTerms).should('have.been.calledOnce')
+ cy.wrap(templateRepository.updateTemplateLicenseTerms).then((stub) => {
+ const args = (stub as unknown as sinon.SinonStub).getCall(0).args as [
+ number,
+ { name?: string }
+ ]
+ expect(args[0]).to.equal(5)
+ expect(args[1].name).to.match(/CC0 1\.0|CC BY 4\.0/)
+ })
+ cy.wrap(onSuccess).should('have.been.calledOnce')
+ })
+
+ it('shows an error alert when license update fails', () => {
+ templateRepository.updateTemplateLicenseTerms = cy
+ .stub()
+ .rejects(new Error('Cannot update license'))
+
+ mountEditTemplateLicenseTerms()
+
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.findByText(/Cannot update license/i).should('exist')
+ })
+
+ it('renders a Close button when onCancel is provided', () => {
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: /Close/i }).should('exist')
+ })
+
+ it('calls onCancel when Close is clicked', () => {
+ const onCancel = cy.stub().as('onCancel')
+
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: /Close/i }).click()
+
+ cy.get('@onCancel').should('have.been.calledOnce')
+ })
+
+ it('renders the custom terms fields when the template uses custom terms', () => {
+ mountEditTemplateLicenseTerms(
+ cy.stub(),
+ TemplateMother.create({
+ id: 5,
+ name: 'Tpl',
+ termsOfUse: {
+ termsOfAccess: { fileAccessRequest: false },
+ customTerms: {
+ termsOfUse: 'Existing custom terms',
+ confidentialityDeclaration: 'Confidentiality',
+ specialPermissions: 'Permissions',
+ restrictions: 'Restrictions',
+ citationRequirements: 'Citations',
+ depositorRequirements: 'Depositor requirements',
+ conditions: 'Conditions',
+ disclaimer: 'Disclaimer'
+ }
+ }
+ })
+ )
+
+ cy.findByTestId('customTerms.termsOfUse').should('have.value', 'Existing custom terms')
+ cy.findByTestId('customTerms.confidentialityDeclaration').should(
+ 'have.value',
+ 'Confidentiality'
+ )
+ })
+
+ it('submits custom terms when the custom terms option is selected', () => {
+ templateRepository.updateTemplateLicenseTerms = cy.stub().resolves()
+
+ mountEditTemplateLicenseTerms(
+ cy.stub(),
+ TemplateMother.create({
+ id: 5,
+ name: 'Tpl',
+ termsOfUse: {
+ termsOfAccess: { fileAccessRequest: false },
+ customTerms: {
+ termsOfUse: 'Existing custom terms',
+ confidentialityDeclaration: '',
+ specialPermissions: '',
+ restrictions: '',
+ citationRequirements: '',
+ depositorRequirements: '',
+ conditions: '',
+ disclaimer: ''
+ }
+ }
+ })
+ )
+
+ cy.findByTestId('customTerms.termsOfUse').clear().type('Updated custom terms')
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.wrap(templateRepository.updateTemplateLicenseTerms).should('have.been.calledWith', 5, {
+ customTerms: {
+ termsOfUse: 'Updated custom terms',
+ confidentialityDeclaration: '',
+ specialPermissions: '',
+ restrictions: '',
+ citationRequirements: '',
+ depositorRequirements: '',
+ conditions: '',
+ disclaimer: ''
+ }
+ })
+ })
+})
diff --git a/tests/component/sections/templates/edit-template-terms/EditTemplateTerms.spec.tsx b/tests/component/sections/templates/edit-template-terms/EditTemplateTerms.spec.tsx
new file mode 100644
index 000000000..23d27b05a
--- /dev/null
+++ b/tests/component/sections/templates/edit-template-terms/EditTemplateTerms.spec.tsx
@@ -0,0 +1,112 @@
+import { useLocation } from 'react-router-dom'
+import { EditTemplateTerms } from '@/sections/templates/edit-template-terms'
+import { LicenseRepository } from '@/licenses/domain/repositories/LicenseRepository'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { License } from '@/licenses/domain/models/License'
+import { TemplateMother } from '../TemplateMother'
+
+const templateRepository: TemplateRepository = {} as TemplateRepository
+const licenseRepository: LicenseRepository = {} as LicenseRepository
+
+const mockLicenses: License[] = [
+ {
+ id: 1,
+ name: 'CC0 1.0',
+ uri: 'http://creativecommons.org/publicdomain/zero/1.0',
+ iconUri: '',
+ active: true,
+ isDefault: true,
+ sortOrder: 0
+ }
+]
+
+const template = TemplateMother.create({
+ id: 5,
+ name: 'Tpl',
+ license: mockLicenses[0],
+ termsOfUse: {
+ termsOfAccess: {
+ fileAccessRequest: true,
+ termsOfAccessForRestrictedFiles: 'Existing terms'
+ }
+ }
+})
+
+const LocationDisplay = () => {
+ const location = useLocation()
+ return {location.pathname}
+}
+
+describe('EditTemplateTerms', () => {
+ beforeEach(() => {
+ templateRepository.getTemplate = cy.stub().resolves(template)
+ licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses)
+ })
+
+ const mountEditTemplateTerms = (
+ initialEntries = ['/templates/edit?id=5&ownerId=root&editMode=LICENSE']
+ ) =>
+ cy.customMount(
+ <>
+
+
+ >,
+ initialEntries
+ )
+
+ it('shows the loading skeleton while the template is loading', () => {
+ const delayedTime = 200
+ templateRepository.getTemplate = cy.stub().callsFake(() => {
+ return Cypress.Promise.delay(delayedTime).then(() => template)
+ })
+
+ mountEditTemplateTerms()
+
+ cy.clock()
+ cy.findByTestId('edit-template-terms-skeleton').should('exist')
+
+ cy.tick(delayedTime)
+ cy.findByTestId('edit-template-terms-skeleton').should('not.exist')
+ })
+
+ it('renders the page title and tabs', () => {
+ mountEditTemplateTerms()
+
+ cy.findByRole('heading', { name: /Edit Template Terms/i }).should('exist')
+ cy.findByRole('tab', { name: /Dataset Terms/i }).should('exist')
+ cy.findByRole('tab', { name: /Terms of Access/i }).should('exist')
+ })
+
+ it('shows the success alert after navigating from template creation', () => {
+ mountEditTemplateTerms([
+ {
+ pathname: '/templates/edit',
+ search: '?id=5&ownerId=root&editMode=LICENSE',
+ state: { fromCreateTemplate: true }
+ }
+ ])
+
+ cy.findByText(/Template has been created/i).should('exist')
+ })
+
+ it('shows an error alert when the template fetch fails', () => {
+ templateRepository.getTemplate = cy.stub().rejects(new Error('Template fetch boom'))
+
+ mountEditTemplateTerms()
+
+ cy.findByText(/Something went wrong getting the template\. Try again later\./i).should('exist')
+ })
+
+ it('navigates back to the templates list when Close is clicked', () => {
+ mountEditTemplateTerms()
+
+ cy.findByTestId('location-display').should('have.text', '/templates/edit')
+ cy.findByRole('button', { name: 'Close' }).click()
+ cy.findByTestId('location-display').should('have.text', '/root/templates')
+ })
+})
diff --git a/tests/component/sections/templates/edit-template-terms/EditTemplateTermsOfAccess.spec.tsx b/tests/component/sections/templates/edit-template-terms/EditTemplateTermsOfAccess.spec.tsx
new file mode 100644
index 000000000..73b04fe1d
--- /dev/null
+++ b/tests/component/sections/templates/edit-template-terms/EditTemplateTermsOfAccess.spec.tsx
@@ -0,0 +1,200 @@
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { EditTemplateTermsOfAccess } from '@/sections/templates/edit-template-terms/EditTemplateTermsOfAccess'
+import { TemplateMother } from '../TemplateMother'
+
+const templateRepository: TemplateRepository = {} as TemplateRepository
+
+const baseTemplate = TemplateMother.create({
+ id: 9,
+ name: 'Tpl',
+ termsOfUse: {
+ termsOfAccess: {
+ fileAccessRequest: true,
+ termsOfAccessForRestrictedFiles: 'Existing terms'
+ }
+ }
+})
+
+describe('EditTemplateTermsOfAccess', () => {
+ const mountEditTemplateTermsOfAccess = (onSuccess: () => void = cy.stub()) =>
+ cy.customMount(
+
+ )
+
+ it('pre-fills the request-access checkbox and terms textarea', () => {
+ mountEditTemplateTermsOfAccess()
+
+ cy.findByLabelText(/Enable access request/i).should('be.checked')
+ cy.findByText(/Terms of Access for Restricted Files/i).should('exist')
+ })
+
+ it('falls back to default terms of access when the template has none', () => {
+ cy.customMount(
+
+ )
+
+ cy.findByLabelText(/Enable access request/i).should('not.be.checked')
+ cy.findByLabelText(/Terms of Access for Restricted Files/i).should('have.value', '')
+ cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled')
+ })
+
+ it('submits the terms of access', () => {
+ templateRepository.updateTemplateTermsOfAccess = cy.stub().resolves()
+ const onSuccess = cy.stub()
+
+ mountEditTemplateTermsOfAccess(onSuccess)
+
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.wrap(templateRepository.updateTemplateTermsOfAccess).should('have.been.calledOnce')
+ cy.wrap(templateRepository.updateTemplateTermsOfAccess).then((stub) => {
+ const args = (stub as unknown as sinon.SinonStub).getCall(0).args as [
+ number,
+ { fileAccessRequest: boolean }
+ ]
+ expect(args[0]).to.equal(9)
+ expect(args[1].fileAccessRequest).to.equal(true)
+ })
+ cy.wrap(onSuccess).should('have.been.calledOnce')
+ })
+
+ it('disables Save when request-access is off and terms are empty', () => {
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled')
+ })
+
+ it('shows the required message when request-access is off and terms are cleared', () => {
+ cy.customMount(
+
+ )
+
+ cy.findByLabelText(/Terms of Access for Restricted Files/i)
+ .clear()
+ .blur()
+ cy.findByText(
+ 'Add information about terms of access for restricted files when request access is disabled.'
+ ).should('exist')
+ cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled')
+ })
+
+ it('renders a Close button when onCancel is provided', () => {
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: /Close/i }).should('exist')
+ })
+
+ it('calls onCancel when Close is clicked', () => {
+ const onCancel = cy.stub().as('onCancel')
+
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: /Close/i }).click()
+
+ cy.get('@onCancel').should('have.been.calledOnce')
+ })
+
+ it('shows an error message when the update fails', () => {
+ templateRepository.updateTemplateTermsOfAccess = cy.stub().rejects(new Error('TOA boom'))
+
+ mountEditTemplateTermsOfAccess()
+
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.findByText(/TOA boom/i).should('exist')
+ })
+
+ it('shows the saving label while the update is in flight', () => {
+ templateRepository.updateTemplateTermsOfAccess = cy
+ .stub()
+ .callsFake(() => Cypress.Promise.delay(200))
+
+ mountEditTemplateTermsOfAccess()
+
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+ cy.findByRole('button', { name: 'Saving' }).should('be.disabled')
+ })
+
+ it('enables Save and submits when request access is off but terms are provided', () => {
+ templateRepository.updateTemplateTermsOfAccess = cy.stub().resolves()
+
+ cy.customMount(
+
+ )
+
+ cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled')
+ cy.findByLabelText(/Terms of Access for Restricted Files/i).type('Provide contact details')
+ cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled').click()
+
+ cy.wrap(templateRepository.updateTemplateTermsOfAccess).should('have.been.calledWith', 9, {
+ fileAccessRequest: false,
+ termsOfAccessForRestrictedFiles: 'Provide contact details'
+ })
+ })
+})
diff --git a/tests/component/sections/templates/edit-template-terms/useUpdateTemplateLicenseTerms.spec.tsx b/tests/component/sections/templates/edit-template-terms/useUpdateTemplateLicenseTerms.spec.tsx
new file mode 100644
index 000000000..405946b26
--- /dev/null
+++ b/tests/component/sections/templates/edit-template-terms/useUpdateTemplateLicenseTerms.spec.tsx
@@ -0,0 +1,109 @@
+import { act, renderHook } 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 { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { UpdateTemplateLicenseTermsInfo } from '@/templates/domain/models/UpdateTemplateLicenseTermsInfo'
+import { useUpdateTemplateLicenseTerms } from '@/sections/templates/edit-template-terms/useUpdateTemplateLicenseTerms'
+
+const payload: UpdateTemplateLicenseTermsInfo = { name: 'CC0 1.0' }
+
+const createI18n = (): I18nInstance => {
+ const instance = i18next.createInstance()
+ void instance.use(initReactI18next).init({
+ lng: 'en',
+ fallbackLng: 'en',
+ ns: ['datasetTemplates'],
+ defaultNS: 'datasetTemplates',
+ initImmediate: false,
+ resources: {
+ en: {
+ datasetTemplates: {
+ editTemplate: { errors: { saveLicenseFailed: 'Update failed.' } }
+ }
+ }
+ }
+ })
+ return instance
+}
+
+const createRepository = (): TemplateRepository => ({} as TemplateRepository)
+
+describe('useUpdateTemplateLicenseTerms', () => {
+ let i18n: I18nInstance
+
+ beforeEach(() => {
+ i18n = createI18n()
+ })
+
+ it('updates license/terms and calls onSuccess', async () => {
+ const repository = createRepository()
+ repository.updateTemplateLicenseTerms = cy.stub().resolves()
+ const onSuccess = cy.stub()
+
+ const { result } = renderHook(
+ () => useUpdateTemplateLicenseTerms({ templateRepository: repository, onSuccess }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ const ok = await result.current.handleUpdateLicenseTerms(7, payload)
+ expect(ok).to.equal(true)
+ })
+
+ expect(repository.updateTemplateLicenseTerms).to.have.been.calledWith(7, payload)
+ expect(onSuccess).to.have.been.calledOnce
+ expect(result.current.error).to.equal(null)
+ expect(result.current.isLoading).to.equal(false)
+ })
+
+ it('surfaces a WriteError reason', async () => {
+ const repository = createRepository()
+ repository.updateTemplateLicenseTerms = cy.stub().rejects(new WriteError('Bad payload'))
+
+ const { result } = renderHook(
+ () => useUpdateTemplateLicenseTerms({ templateRepository: repository, onSuccess: cy.stub() }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ const ok = await result.current.handleUpdateLicenseTerms(7, payload)
+ expect(ok).to.equal(false)
+ })
+
+ expect(result.current.error).to.equal('Bad payload')
+ })
+
+ it('surfaces a regular Error message', async () => {
+ const repository = createRepository()
+ repository.updateTemplateLicenseTerms = cy.stub().rejects(new Error('Network error'))
+
+ const { result } = renderHook(
+ () => useUpdateTemplateLicenseTerms({ templateRepository: repository, onSuccess: cy.stub() }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ await result.current.handleUpdateLicenseTerms(7, payload)
+ })
+
+ expect(result.current.error).to.equal('Network error')
+ })
+
+ it('falls back to the generic message for non-Error rejections', async () => {
+ const repository = createRepository()
+ repository.updateTemplateLicenseTerms = cy.stub().rejects('weird')
+
+ const { result } = renderHook(
+ () => useUpdateTemplateLicenseTerms({ templateRepository: repository, onSuccess: cy.stub() }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ await result.current.handleUpdateLicenseTerms(7, payload)
+ })
+
+ expect(result.current.error).to.equal('Update failed.')
+ })
+})
diff --git a/tests/component/sections/templates/edit-template-terms/useUpdateTemplateTermsOfAccess.spec.tsx b/tests/component/sections/templates/edit-template-terms/useUpdateTemplateTermsOfAccess.spec.tsx
new file mode 100644
index 000000000..15d0a464f
--- /dev/null
+++ b/tests/component/sections/templates/edit-template-terms/useUpdateTemplateTermsOfAccess.spec.tsx
@@ -0,0 +1,111 @@
+import { act, renderHook } 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 { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { TermsOfAccess } from '@/dataset/domain/models/Dataset'
+import { useUpdateTemplateTermsOfAccess } from '@/sections/templates/edit-template-terms/useUpdateTemplateTermsOfAccess'
+
+const termsOfAccess: TermsOfAccess = { fileAccessRequest: true }
+
+const createI18n = (): I18nInstance => {
+ const instance = i18next.createInstance()
+ void instance.use(initReactI18next).init({
+ lng: 'en',
+ fallbackLng: 'en',
+ ns: ['datasetTemplates'],
+ defaultNS: 'datasetTemplates',
+ initImmediate: false,
+ resources: {
+ en: {
+ datasetTemplates: {
+ editTemplate: { errors: { saveTermsOfAccessFailed: 'Update failed.' } }
+ }
+ }
+ }
+ })
+ return instance
+}
+
+const createRepository = (): TemplateRepository => ({} as TemplateRepository)
+
+describe('useUpdateTemplateTermsOfAccess', () => {
+ let i18n: I18nInstance
+
+ beforeEach(() => {
+ i18n = createI18n()
+ })
+
+ it('updates terms of access and calls onSuccess', async () => {
+ const repository = createRepository()
+ repository.updateTemplateTermsOfAccess = cy.stub().resolves()
+ const onSuccess = cy.stub()
+
+ const { result } = renderHook(
+ () => useUpdateTemplateTermsOfAccess({ templateRepository: repository, onSuccess }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ const ok = await result.current.handleUpdateTermsOfAccess(7, termsOfAccess)
+ expect(ok).to.equal(true)
+ })
+
+ expect(repository.updateTemplateTermsOfAccess).to.have.been.calledWith(7, termsOfAccess)
+ expect(onSuccess).to.have.been.calledOnce
+ expect(result.current.error).to.equal(null)
+ })
+
+ it('surfaces a WriteError reason', async () => {
+ const repository = createRepository()
+ repository.updateTemplateTermsOfAccess = cy.stub().rejects(new WriteError('Forbidden'))
+
+ const { result } = renderHook(
+ () =>
+ useUpdateTemplateTermsOfAccess({ templateRepository: repository, onSuccess: cy.stub() }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ const ok = await result.current.handleUpdateTermsOfAccess(7, termsOfAccess)
+ expect(ok).to.equal(false)
+ })
+
+ expect(result.current.error).to.equal('Forbidden')
+ })
+
+ it('surfaces a regular Error message', async () => {
+ const repository = createRepository()
+ repository.updateTemplateTermsOfAccess = cy.stub().rejects(new Error('Boom'))
+
+ const { result } = renderHook(
+ () =>
+ useUpdateTemplateTermsOfAccess({ templateRepository: repository, onSuccess: cy.stub() }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ await result.current.handleUpdateTermsOfAccess(7, termsOfAccess)
+ })
+
+ expect(result.current.error).to.equal('Boom')
+ })
+
+ it('falls back to the generic message for non-Error rejections', async () => {
+ const repository = createRepository()
+ repository.updateTemplateTermsOfAccess = cy.stub().rejects('weird')
+
+ const { result } = renderHook(
+ () =>
+ useUpdateTemplateTermsOfAccess({ templateRepository: repository, onSuccess: cy.stub() }),
+ { wrapper: ({ children }) => {children} }
+ )
+
+ await act(async () => {
+ await result.current.handleUpdateTermsOfAccess(7, termsOfAccess)
+ })
+
+ expect(result.current.error).to.equal('Update failed.')
+ })
+})
diff --git a/tests/component/sections/templates/useCopyTemplate.spec.tsx b/tests/component/sections/templates/useCopyTemplate.spec.tsx
new file mode 100644
index 000000000..e80b4d3ff
--- /dev/null
+++ b/tests/component/sections/templates/useCopyTemplate.spec.tsx
@@ -0,0 +1,148 @@
+import { act, renderHook } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import i18next, { i18n as I18nInstance } from 'i18next'
+import { initReactI18next } from 'react-i18next'
+import { toast } from 'react-toastify'
+import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { useCopyTemplate } from '@/sections/templates/useCopyTemplate'
+import { CitationMetadataBlockInfoMother } from '../../metadata-block-info/domain/models/CitationMetadataBlockInfoMother'
+import { TemplateMother } from './TemplateMother'
+
+const createI18n = (): I18nInstance => {
+ const instance = i18next.createInstance()
+ void instance.use(initReactI18next).init({
+ lng: 'en',
+ fallbackLng: 'en',
+ ns: ['datasetTemplates'],
+ defaultNS: 'datasetTemplates',
+ initImmediate: false,
+ resources: {
+ en: {
+ datasetTemplates: {
+ copyNamePrefix: 'copy {{name}}',
+ alerts: {
+ copySuccess: 'Template copied.',
+ copyError: 'Something went wrong copying the template. Try again later.'
+ }
+ }
+ }
+ }
+ })
+
+ return instance
+}
+
+describe('useCopyTemplate', () => {
+ let templateRepository: TemplateRepository
+ let metadataBlockInfoRepository: MetadataBlockInfoRepository
+ let i18n: I18nInstance
+
+ beforeEach(() => {
+ templateRepository = {} as TemplateRepository
+ metadataBlockInfoRepository = {} as MetadataBlockInfoRepository
+ i18n = createI18n()
+ cy.stub(toast, 'success')
+ cy.stub(toast, 'error')
+ })
+
+ it('copies metadata and terms of use values to the copied template', async () => {
+ const customTerms = {
+ termsOfUse: 'Existing custom terms',
+ confidentialityDeclaration: 'Confidentiality',
+ specialPermissions: 'Special permissions',
+ restrictions: 'Restrictions',
+ citationRequirements: 'Citation requirements',
+ depositorRequirements: 'Depositor requirements',
+ conditions: 'Conditions',
+ disclaimer: 'Disclaimer'
+ }
+ const termsOfAccess = {
+ fileAccessRequest: true,
+ termsOfAccessForRestrictedFiles: 'Access is restricted.'
+ }
+ const templateToCopy = TemplateMother.create({
+ id: 10,
+ name: 'Template Copy',
+ isDefault: false,
+ datasetMetadataBlocks: [
+ {
+ name: 'citation',
+ fields: {
+ title: 'My Title'
+ }
+ }
+ ],
+ instructions: [
+ {
+ instructionField: 'title',
+ instructionText: 'Provide a clear title.'
+ }
+ ],
+ termsOfUse: {
+ customTerms,
+ termsOfAccess
+ }
+ })
+ const copiedTemplate = TemplateMother.create({
+ id: 11,
+ name: 'copy Template Copy'
+ })
+
+ templateRepository.getTemplate = cy.stub().resolves(templateToCopy)
+ templateRepository.createTemplate = cy.stub().resolves()
+ templateRepository.getTemplatesByCollectionId = cy
+ .stub()
+ .resolves([templateToCopy, copiedTemplate])
+ templateRepository.updateTemplateLicenseTerms = cy.stub().resolves()
+ templateRepository.updateTemplateTermsOfAccess = cy.stub().resolves()
+ metadataBlockInfoRepository.getByCollectionId = cy
+ .stub()
+ .resolves([CitationMetadataBlockInfoMother.get()])
+
+ const { result } = renderHook(
+ () =>
+ useCopyTemplate({
+ collectionId: 'root',
+ templateRepository,
+ metadataBlockInfoRepository
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ )
+
+ let didCopy: boolean | undefined
+
+ await act(async () => {
+ didCopy = await result.current.copyTemplate(10)
+ })
+
+ expect(didCopy).to.equal(true)
+ expect(templateRepository.createTemplate).to.have.been.calledWith(
+ {
+ name: 'copy Template Copy',
+ isDefault: false,
+ fields: [
+ {
+ typeName: 'title',
+ multiple: false,
+ typeClass: 'primitive',
+ value: 'My Title'
+ }
+ ],
+ instructions: templateToCopy.instructions
+ },
+ 'root'
+ )
+ expect(templateRepository.updateTemplateLicenseTerms).to.have.been.calledWith(11, {
+ customTerms
+ })
+ expect(templateRepository.updateTemplateTermsOfAccess).to.have.been.calledWith(
+ 11,
+ termsOfAccess
+ )
+ expect(toast.success).to.have.been.calledWith('Template copied.')
+ expect(result.current.isCopyingTemplate).to.equal(false)
+ })
+})
diff --git a/tests/component/sections/templates/useSetTemplateAsDefault.spec.tsx b/tests/component/sections/templates/useSetTemplateAsDefault.spec.tsx
new file mode 100644
index 000000000..bebb5189c
--- /dev/null
+++ b/tests/component/sections/templates/useSetTemplateAsDefault.spec.tsx
@@ -0,0 +1,157 @@
+import { renderHook, act, waitFor } from '@testing-library/react'
+import { WriteError } from '@iqss/dataverse-client-javascript'
+import { toast } from 'react-toastify'
+import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository'
+import { useSetTemplateAsDefault } from '@/sections/templates/useSetTemplateAsDefault'
+
+describe('useSetTemplateAsDefault', () => {
+ let templateRepository: TemplateRepository
+ const collectionId = 'test-collection'
+
+ beforeEach(() => {
+ templateRepository = {} as TemplateRepository
+ cy.stub(toast, 'success')
+ cy.stub(toast, 'error')
+ })
+
+ it('should initialize with default state', async () => {
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ await waitFor(() => {
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ expect(typeof result.current.handleSetTemplateAsDefault).to.deep.equal('function')
+ expect(typeof result.current.handleUnsetTemplateAsDefault).to.deep.equal('function')
+ })
+ })
+
+ describe('handleSetTemplateAsDefault', () => {
+ it('should successfully set template as default', async () => {
+ templateRepository.setTemplateAsDefault = cy.stub().resolves()
+
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ let success: boolean | undefined
+
+ await act(async () => {
+ success = await result.current.handleSetTemplateAsDefault(123)
+ })
+
+ expect(success).to.equal(true)
+ expect(templateRepository.setTemplateAsDefault).to.have.been.calledWith(123, collectionId)
+ expect(toast.success).to.have.been.calledOnce
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ })
+
+ it('should handle WriteError and show the reason', async () => {
+ templateRepository.setTemplateAsDefault = cy
+ .stub()
+ .rejects(new WriteError('Permission denied'))
+
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ let success: boolean | undefined
+
+ await act(async () => {
+ success = await result.current.handleSetTemplateAsDefault(123)
+ })
+
+ expect(success).to.equal(false)
+ expect(toast.error).to.have.been.calledWith('Permission denied')
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ })
+
+ it('should handle WriteError and fall back to the error message when no reason is present', async () => {
+ const error = new WriteError()
+
+ templateRepository.setTemplateAsDefault = cy.stub().rejects(error)
+
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ await act(async () => {
+ await result.current.handleSetTemplateAsDefault(123)
+ })
+
+ expect(toast.error).to.have.been.calledWith(error.message)
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ })
+
+ it('should handle unknown errors and show generic error toast', async () => {
+ templateRepository.setTemplateAsDefault = cy.stub().rejects(new Error('Network error'))
+
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ await act(async () => {
+ await result.current.handleSetTemplateAsDefault(123)
+ })
+
+ expect(toast.error).to.have.been.calledOnce
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ })
+ })
+
+ describe('handleUnsetTemplateAsDefault', () => {
+ it('should successfully unset template as default', async () => {
+ templateRepository.unsetTemplateAsDefault = cy.stub().resolves()
+
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ let success: boolean | undefined
+
+ await act(async () => {
+ success = await result.current.handleUnsetTemplateAsDefault()
+ })
+
+ expect(success).to.equal(true)
+ expect(templateRepository.unsetTemplateAsDefault).to.have.been.calledWith(collectionId)
+ expect(toast.success).to.have.been.calledOnce
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ })
+
+ it('should handle WriteError and show the reason', async () => {
+ templateRepository.unsetTemplateAsDefault = cy
+ .stub()
+ .rejects(new WriteError('Not authorized'))
+
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ let success: boolean | undefined
+
+ await act(async () => {
+ success = await result.current.handleUnsetTemplateAsDefault()
+ })
+
+ expect(success).to.equal(false)
+ expect(toast.error).to.have.been.calledWith('Not authorized')
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ })
+
+ it('should handle unknown errors and show generic error toast', async () => {
+ templateRepository.unsetTemplateAsDefault = cy.stub().rejects(new Error('Network error'))
+
+ const { result } = renderHook(() =>
+ useSetTemplateAsDefault({ collectionId, templateRepository })
+ )
+
+ await act(async () => {
+ await result.current.handleUnsetTemplateAsDefault()
+ })
+
+ expect(toast.error).to.have.been.calledOnce
+ expect(result.current.isSettingDefault).to.deep.equal(false)
+ })
+ })
+})
diff --git a/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx b/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx
index 694ef000f..5e85f0a98 100644
--- a/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx
+++ b/tests/e2e-integration/e2e/sections/templates/DatasetTemplates.spec.tsx
@@ -4,7 +4,7 @@ import { DatasetHelper } from '@tests/e2e-integration/shared/datasets/DatasetHel
const CREATE_TEMPLATE_PAGE_URL = `${FRONTEND_BASE_PATH}/root/templates/create`
const TEMPLATES_PAGE_URL = `${FRONTEND_BASE_PATH}/root/templates`
-
+const CREATE_DATASET_PAGE_URL = `${FRONTEND_BASE_PATH}/datasets/root/create`
describe('Dataset Templates', () => {
let templateName: string
@@ -62,7 +62,7 @@ describe('Dataset Templates', () => {
cy.findByRole('button', { name: 'Save + Add Terms' }).click()
cy.findByText(/Success! Template has been created./i).should('exist')
- cy.findByTestId('cancel-edit-template-terms-button').click()
+ cy.findByTestId('cancel-edit-template-license-terms-button').click()
cy.url().should('include', TEMPLATES_PAGE_URL)
cy.findByText(templateName)
@@ -108,7 +108,7 @@ describe('Dataset Templates', () => {
})
cy.findByRole('button', { name: 'Save + Add Terms' }).click()
- cy.findByTestId('cancel-edit-template-terms-button').click()
+ cy.findByTestId('cancel-edit-template-license-terms-button').click()
cy.url().should('include', TEMPLATES_PAGE_URL)
cy.findByText(templateName)
@@ -122,4 +122,134 @@ describe('Dataset Templates', () => {
cy.findByText(/Template deleted./i).should('exist')
})
+
+ it('toggles a template as default via the UI', () => {
+ cy.visit(CREATE_TEMPLATE_PAGE_URL)
+
+ cy.findByLabelText(/Template Name/).type(templateName, { force: true })
+ cy.findByLabelText(/^Title/i).type('Default Template Title', { force: true })
+
+ cy.findByText('Author')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText(/^Name/i).type('Test Author', { force: true })
+ })
+
+ cy.findByText('Point of Contact')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText(/^E-mail/i).type('test@example.com', { force: true })
+ })
+
+ cy.findByText('Description')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText(/^Text/i).type('Template description', { force: true })
+ })
+
+ cy.findByText('Subject')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText('Toggle options menu').click({ force: true })
+ cy.findByLabelText('Agricultural Sciences').click({ force: true })
+ })
+
+ cy.findByRole('button', { name: 'Save + Add Terms' }).click()
+ cy.findByText(/Success! Template has been created./i).should('exist')
+ cy.findByTestId('cancel-edit-template-license-terms-button').click()
+ cy.url().should('include', TEMPLATES_PAGE_URL)
+
+ cy.findByText(templateName)
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).click()
+ })
+
+ cy.findByText(
+ /The template has been selected as the default template for this dataverse./i
+ ).should('exist')
+
+ cy.contains('tr', templateName).should('contain.text', 'Default')
+ cy.contains('tr', templateName).within(() => {
+ cy.findByRole('button', { name: 'Default' }).should('not.be.disabled')
+ cy.findByRole('button', { name: 'Make Default' }).should('not.exist')
+ })
+
+ cy.contains('tr', templateName).within(() => {
+ cy.findByRole('button', { name: 'Default' }).click({ force: true })
+ })
+
+ cy.findByText(
+ /The template has been removed as the default template for this dataverse./i
+ ).should('exist')
+
+ cy.findByText(templateName)
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).should('exist')
+ })
+ })
+
+ it('sets a template as default and verifies it is auto-selected when creating a dataset', () => {
+ cy.visit(CREATE_TEMPLATE_PAGE_URL)
+
+ cy.findByLabelText(/Template Name/).type(templateName, { force: true })
+ cy.findByLabelText(/^Title/i).type('Auto Selected Title', { force: true })
+
+ cy.findByText('Author')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText(/^Name/i).type('Test Author', { force: true })
+ })
+
+ cy.findByText('Point of Contact')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText(/^E-mail/i).type('test@example.com', { force: true })
+ })
+
+ cy.findByText('Description')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText(/^Text/i).type('Auto selected description', { force: true })
+ })
+
+ cy.findByText('Subject')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText('Toggle options menu').click({ force: true })
+ cy.findByLabelText('Agricultural Sciences').click({ force: true })
+ })
+
+ cy.findByRole('button', { name: 'Save + Add Terms' }).click()
+ cy.findByText(/Success! Template has been created./i).should('exist')
+ cy.findByTestId('cancel-edit-template-license-terms-button').click()
+
+ cy.findByText(templateName)
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: 'Make Default' }).click()
+ })
+
+ cy.findByText(
+ /The template has been selected as the default template for this dataverse./i
+ ).should('exist')
+
+ cy.visit(CREATE_DATASET_PAGE_URL)
+
+ cy.findByTestId('dataset-template-select')
+ .should('exist')
+ .within(() => {
+ cy.findByText(templateName).should('exist')
+ cy.findByText('None').should('not.exist')
+ })
+
+ cy.findByLabelText(/^Title/i).should('have.value', 'Auto Selected Title')
+
+ cy.findByText('Description')
+ .closest('.row')
+ .within(() => {
+ cy.findByLabelText(/^Text/i).should('have.value', 'Auto selected description')
+ })
+ })
})
diff --git a/tests/e2e-integration/e2e/sections/templates/EditDatasetTemplate.spec.tsx b/tests/e2e-integration/e2e/sections/templates/EditDatasetTemplate.spec.tsx
new file mode 100644
index 000000000..255c76e12
--- /dev/null
+++ b/tests/e2e-integration/e2e/sections/templates/EditDatasetTemplate.spec.tsx
@@ -0,0 +1,101 @@
+import { TestsUtils } from '../../../shared/TestsUtils'
+import { DatasetHelper } from '@tests/e2e-integration/shared/datasets/DatasetHelper'
+import { FRONTEND_BASE_PATH } from '@tests/e2e-integration/shared/basePath'
+
+const TEMPLATES_PAGE_URL = `${FRONTEND_BASE_PATH}/root/templates`
+const editUrl = (id: number, mode: 'METADATA' | 'LICENSE') =>
+ `${FRONTEND_BASE_PATH}/templates/edit?id=${id}&ownerId=root&editMode=${mode}`
+
+describe('Edit Dataset Template', () => {
+ let templateId: number | undefined
+
+ beforeEach(() => {
+ TestsUtils.login().then((token) => {
+ cy.wrap(TestsUtils.setup(token))
+ })
+
+ cy.wrap(null).then(async () => {
+ const created = await DatasetHelper.createTemplate()
+ templateId = created.id
+ })
+ })
+
+ afterEach(() => {
+ cy.wrap(null).then(async () => {
+ if (!templateId) return
+ await DatasetHelper.deleteDatasetTemplate(templateId).catch(() => {})
+ templateId = undefined
+ })
+ })
+
+ it('opens the edit-metadata page from the templates listing dropdown', () => {
+ cy.visit(TEMPLATES_PAGE_URL)
+
+ cy.findAllByText('Dataset Template One')
+ .first()
+ .closest('tr')
+ .within(() => {
+ cy.findByRole('button', { name: /Edit Template/i }).click({ force: true })
+ })
+
+ cy.findByText('Metadata').click({ force: true })
+
+ cy.url().should('match', /\/templates\/edit\?.*editMode=METADATA/)
+ cy.findByRole('heading', { name: /Edit Template Metadata/i }).should('exist')
+ })
+
+ it('updates the template name and redirects to the templates listing', () => {
+ const renamedTemplate = `Renamed Template ${Date.now()}`
+
+ cy.wrap(null).then(() => {
+ if (!templateId) throw new Error('Template not created')
+ cy.visit(editUrl(templateId, 'METADATA'))
+ })
+
+ cy.findByLabelText(/Template Name/).should('have.value', 'Dataset Template One')
+ cy.findByLabelText(/Template Name/)
+ .clear()
+ .type(renamedTemplate)
+
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.url().should('include', TEMPLATES_PAGE_URL)
+ cy.findByText(renamedTemplate).should('exist')
+ })
+
+ it('renders the terms page with two horizontal tabs', () => {
+ cy.wrap(null).then(() => {
+ if (!templateId) throw new Error('Template not created')
+ cy.visit(editUrl(templateId, 'LICENSE'))
+ })
+
+ cy.findByRole('tab', { name: /Dataset Terms/i }).should('exist')
+ cy.findByRole('tab', { name: /Restricted Files \+ Terms of Access/i }).should('exist')
+ })
+
+ it('updates the terms-of-access and redirects to the templates listing', () => {
+ cy.wrap(null).then(() => {
+ if (!templateId) throw new Error('Template not created')
+ cy.visit(editUrl(templateId, 'LICENSE'))
+ })
+
+ cy.findByRole('tab', { name: /Restricted Files \+ Terms of Access/i }).click()
+
+ cy.findByLabelText(/Enable access request/i).check({ force: true })
+
+ cy.findByRole('button', { name: 'Save Changes' }).click()
+
+ cy.url().should('include', TEMPLATES_PAGE_URL)
+ })
+
+ it('cancels editing and returns to the templates listing', () => {
+ cy.wrap(null).then(() => {
+ if (!templateId) throw new Error('Template not created')
+ cy.visit(editUrl(templateId, 'METADATA'))
+ })
+
+ cy.findByTestId('cancel-edit-template-metadata-button').click()
+
+ cy.url().should('include', TEMPLATES_PAGE_URL)
+ })
+})
diff --git a/tests/e2e-integration/shared/datasets/DatasetHelper.ts b/tests/e2e-integration/shared/datasets/DatasetHelper.ts
index 44724c4fe..fdcab27b6 100644
--- a/tests/e2e-integration/shared/datasets/DatasetHelper.ts
+++ b/tests/e2e-integration/shared/datasets/DatasetHelper.ts
@@ -307,6 +307,17 @@ export class DatasetHelper extends DataverseApiHelper {
)
}
+ static async setTemplateAsDefault(
+ templateId: number,
+ collectionAlias = ROOT_COLLECTION_ALIAS
+ ): Promise {
+ return this.request(`/dataverses/${collectionAlias}/defaultTemplate/${templateId}`, 'PUT')
+ }
+
+ static async unsetTemplateAsDefault(collectionAlias = ROOT_COLLECTION_ALIAS): Promise {
+ return this.request(`/dataverses/${collectionAlias}/defaultTemplate`, 'DELETE')
+ }
+
static async deleteDatasetTemplate(templateId: number): Promise {
try {
return await this.request(`/admin/template/${templateId}`, 'DELETE')