diff --git a/CHANGELOG.md b/CHANGELOG.md index d6df9180c..fe8be5b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added - Dataset Templates UI integration, including create/edit flows, previews, and skeleton states. +- Storage Driver selection in Create Collection form, with dynamic fetching of available storage drivers from the backend. - Dataset Page: added a sidebar to show dataset reviews ### Changed diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index 6690be4a1..c654adc0e 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -49,6 +49,7 @@ "helpText": "Share this collection on your favorite social media networks." }, "editedAlert": "You have successfully updated your collection!", + "storageDriverUpdateFailed": "The collection was created, but the storage driver could not be updated.", "editCollection": { "edit": "Edit", "generalInfo": "General Information", diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index 8e95eec04..273aa2137 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -130,7 +130,8 @@ }, "storage": { "label": "Storage", - "description": "A storage service to be used for datasets in this collection." + "description": "A storage service to be used for datasets in this collection.", + "inheritedOrDefaultOption": "Use inherited or default storage driver ({{storageDriver}})" }, "type": { "label": "Category", diff --git a/public/locales/es/collection.json b/public/locales/es/collection.json index adaa711ba..f5b299cfb 100644 --- a/public/locales/es/collection.json +++ b/public/locales/es/collection.json @@ -49,6 +49,7 @@ "helpText": "Comparte esta colección en tus redes sociales favoritas." }, "editedAlert": "¡Has actualizado tu colección correctamente!", + "storageDriverUpdateFailed": "La colección se creó, pero no se pudo actualizar el driver de almacenamiento.", "editCollection": { "edit": "Editar", "generalInfo": "Información general", diff --git a/public/locales/es/shared.json b/public/locales/es/shared.json index 4dd420367..822ea3d6f 100644 --- a/public/locales/es/shared.json +++ b/public/locales/es/shared.json @@ -130,7 +130,8 @@ }, "storage": { "label": "Almacenamiento", - "description": "Un servicio de almacenamiento que se usará para los conjuntos de datos en esta colección." + "description": "Un servicio de almacenamiento que se usará para los conjuntos de datos en esta colección.", + "inheritedOrDefaultOption": "Usar el driver de almacenamiento heredado o predeterminado ({{storageDriver}})" }, "type": { "label": "Categoría", diff --git a/src/collection/domain/models/AllowedStorageDrivers.ts b/src/collection/domain/models/AllowedStorageDrivers.ts new file mode 100644 index 000000000..8d0dc7eae --- /dev/null +++ b/src/collection/domain/models/AllowedStorageDrivers.ts @@ -0,0 +1 @@ +export type AllowedStorageDrivers = Record diff --git a/src/collection/domain/models/StorageDriver.ts b/src/collection/domain/models/StorageDriver.ts new file mode 100644 index 000000000..308ba635f --- /dev/null +++ b/src/collection/domain/models/StorageDriver.ts @@ -0,0 +1,8 @@ +export interface StorageDriver { + name: string + type?: string + label?: string + directUpload: boolean + directDownload: boolean + uploadOutOfBand: boolean +} diff --git a/src/collection/domain/repositories/CollectionRepository.ts b/src/collection/domain/repositories/CollectionRepository.ts index fbd456e8c..a02745a1c 100644 --- a/src/collection/domain/repositories/CollectionRepository.ts +++ b/src/collection/domain/repositories/CollectionRepository.ts @@ -13,6 +13,8 @@ import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus import { LinkingObjectType } from '../useCases/getCollectionsForLinking' import { CollectionSummary } from '../models/CollectionSummary' import { CollectionLinks } from '../models/CollectionLinks' +import { AllowedStorageDrivers } from '../models/AllowedStorageDrivers' +import { StorageDriver } from '../models/StorageDriver' export interface CollectionRepository { getById: (id?: string) => Promise @@ -58,4 +60,11 @@ export interface CollectionRepository { linkingCollectionIdOrAlias: number | string ): Promise getLinks(collectionIdOrAlias: number | string): Promise + getAllowedStorageDrivers(collectionIdOrAlias: number | string): Promise + getStorageDriver( + collectionIdOrAlias: number | string, + getEffective?: boolean + ): Promise + setStorageDriver(collectionIdOrAlias: number | string, driverLabel: string): Promise + deleteStorageDriver(collectionIdOrAlias: number | string): Promise } diff --git a/src/collection/domain/useCases/deleteCollectionDriver.ts b/src/collection/domain/useCases/deleteCollectionDriver.ts new file mode 100644 index 000000000..fded72290 --- /dev/null +++ b/src/collection/domain/useCases/deleteCollectionDriver.ts @@ -0,0 +1,8 @@ +import { CollectionRepository } from '../repositories/CollectionRepository' + +export async function deleteCollectionDriver( + collectionRepository: CollectionRepository, + collectionIdOrAlias: number | string +): Promise { + return collectionRepository.deleteStorageDriver(collectionIdOrAlias) +} diff --git a/src/collection/domain/useCases/getCollectionAllowedStorageDrivers.ts b/src/collection/domain/useCases/getCollectionAllowedStorageDrivers.ts new file mode 100644 index 000000000..783938760 --- /dev/null +++ b/src/collection/domain/useCases/getCollectionAllowedStorageDrivers.ts @@ -0,0 +1,9 @@ +import { AllowedStorageDrivers } from '../models/AllowedStorageDrivers' +import { CollectionRepository } from '../repositories/CollectionRepository' + +export async function getCollectionAllowedStorageDrivers( + collectionRepository: CollectionRepository, + collectionIdOrAlias: number | string +): Promise { + return collectionRepository.getAllowedStorageDrivers(collectionIdOrAlias) +} diff --git a/src/collection/domain/useCases/getCollectionStorageDriver.ts b/src/collection/domain/useCases/getCollectionStorageDriver.ts new file mode 100644 index 000000000..ac99aed49 --- /dev/null +++ b/src/collection/domain/useCases/getCollectionStorageDriver.ts @@ -0,0 +1,10 @@ +import { StorageDriver } from '../models/StorageDriver' +import { CollectionRepository } from '../repositories/CollectionRepository' + +export async function getCollectionStorageDriver( + collectionRepository: CollectionRepository, + collectionIdOrAlias: number | string, + getEffective?: boolean +): Promise { + return collectionRepository.getStorageDriver(collectionIdOrAlias, getEffective) +} diff --git a/src/collection/domain/useCases/setCollectionDriver.ts b/src/collection/domain/useCases/setCollectionDriver.ts new file mode 100644 index 000000000..7e6d515dd --- /dev/null +++ b/src/collection/domain/useCases/setCollectionDriver.ts @@ -0,0 +1,9 @@ +import { CollectionRepository } from '../repositories/CollectionRepository' + +export async function setCollectionDriver( + collectionRepository: CollectionRepository, + collectionIdOrAlias: number | string, + driverLabel: string +): Promise { + return collectionRepository.setStorageDriver(collectionIdOrAlias, driverLabel) +} diff --git a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts index 01b3cc6a5..484f6dc5e 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -16,7 +16,11 @@ import { deleteCollectionFeaturedItem, getCollectionsForLinking, linkCollection, - getCollectionLinks + getCollectionLinks, + getAllowedCollectionStorageDrivers, + getCollectionStorageDriver, + setCollectionStorageDriver, + deleteCollectionStorageDriver } from '@iqss/dataverse-client-javascript' import { JSCollectionMapper } from '../mappers/JSCollectionMapper' import { CollectionDTO } from '../../domain/useCases/DTOs/CollectionDTO' @@ -34,6 +38,8 @@ import { PublicationStatus } from '@/shared/core/domain/models/PublicationStatus import { CollectionSummary } from '@/collection/domain/models/CollectionSummary' import { LinkingObjectType } from '@/collection/domain/useCases/getCollectionsForLinking' import { CollectionLinks } from '@/collection/domain/models/CollectionLinks' +import { AllowedStorageDrivers } from '@/collection/domain/models/AllowedStorageDrivers' +import { StorageDriver } from '@/collection/domain/models/StorageDriver' export class CollectionJSDataverseRepository implements CollectionRepository { getById(id?: string): Promise { @@ -174,4 +180,23 @@ export class CollectionJSDataverseRepository implements CollectionRepository { getLinks(collectionIdOrAlias: number | string): Promise { return getCollectionLinks.execute(collectionIdOrAlias) } + + getAllowedStorageDrivers(collectionIdOrAlias: number | string): Promise { + return getAllowedCollectionStorageDrivers.execute(collectionIdOrAlias) + } + + getStorageDriver( + collectionIdOrAlias: number | string, + getEffective?: boolean + ): Promise { + return getCollectionStorageDriver.execute(collectionIdOrAlias, getEffective) + } + + setStorageDriver(collectionIdOrAlias: number | string, driverLabel: string): Promise { + return setCollectionStorageDriver.execute(collectionIdOrAlias, driverLabel) + } + + deleteStorageDriver(collectionIdOrAlias: number | string): Promise { + return deleteCollectionStorageDriver.execute(collectionIdOrAlias) + } } diff --git a/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx b/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx index 2f5537d70..180215f2d 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx +++ b/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx @@ -23,6 +23,8 @@ import { CollectionHelper } from '@/sections/collection/CollectionHelper' import { MetadataFieldsHelper } from '../DatasetMetadataForm/MetadataFieldsHelper' import { MetadataBlockInfo } from '@/metadata-block-info/domain/models/MetadataBlockInfo' import { useCollectionRepositories } from '@/shared/contexts/repositories/RepositoriesProvider' +import { useGetCollectionAllowedStorageDrivers } from '@/shared/hooks/useGetCollectionAllowedStorageDrivers' +import { useGetCollectionStorageDriver } from '@/shared/hooks/useGetCollectionStorageDriver' export const METADATA_BLOCKS_NAMES_GROUPER = 'metadataBlockNames' export const USE_FIELDS_FROM_PARENT = 'useFieldsFromParent' @@ -66,6 +68,8 @@ export const EditCreateCollectionForm = ({ const onEditMode = mode === 'edit' const isEditingRootCollection = onEditMode && CollectionHelper.isRootCollection(collection.hierarchy) + const canSelectStorageDriver = user.superuser + const storageDriverCollectionId = onEditMode ? collection.id : parentCollection.id const { metadataBlocksInfo, @@ -102,6 +106,38 @@ export const EditCreateCollectionForm = ({ metadataBlockInfoRepository }) + const { + allowedStorageDrivers, + isLoading: isLoadingAllowedStorageDrivers, + error: allowedStorageDriversError + } = useGetCollectionAllowedStorageDrivers({ + collectionIdOrAlias: storageDriverCollectionId, + collectionRepository, + enabled: canSelectStorageDriver + }) + + const { + storageDriver, + isLoading: isLoadingStorageDriver, + error: storageDriverError + } = useGetCollectionStorageDriver({ + collectionIdOrAlias: storageDriverCollectionId, + collectionRepository, + enabled: canSelectStorageDriver, + getEffective: false + }) + + const { + storageDriver: effectiveStorageDriver, + isLoading: isLoadingEffectiveStorageDriver, + error: effectiveStorageDriverError + } = useGetCollectionStorageDriver({ + collectionIdOrAlias: storageDriverCollectionId, + collectionRepository, + enabled: canSelectStorageDriver, + getEffective: true + }) + const baseInputLevels: FormattedCollectionInputLevels = useDeepCompareMemo(() => { return CollectionFormHelper.defineBaseInputLevels(allMetadataBlocksInfoNormalized) }, [allMetadataBlocksInfoNormalized]) @@ -149,13 +185,19 @@ export const EditCreateCollectionForm = ({ isLoadingMetadataBlocksInfo || isLoadingAllMetadataBlocksInfo || isLoadingCollectionFacets || - isLoadingFacetableMetadataFields + isLoadingFacetableMetadataFields || + isLoadingAllowedStorageDrivers || + isLoadingStorageDriver || + isLoadingEffectiveStorageDriver const dataLoadingErrors = [ metadataBlockInfoError, allMetadataBlocksInfoError, collectionFacetsError, - facetableMetadataFieldsError + facetableMetadataFieldsError, + allowedStorageDriversError, + storageDriverError, + effectiveStorageDriverError ] useEffect(() => { @@ -204,6 +246,21 @@ export const EditCreateCollectionForm = ({ mode === 'edit' ? collection.isFacetRoot : undefined ) + const currentStorageDriverLabel = storageDriver?.label ?? storageDriver?.name + const effectiveStorageDriverLabel = effectiveStorageDriver?.label ?? effectiveStorageDriver?.name + const effectiveStorageDriverDisplayName = + effectiveStorageDriverLabel && allowedStorageDrivers[effectiveStorageDriverLabel] !== undefined + ? allowedStorageDrivers[effectiveStorageDriverLabel] + : effectiveStorageDriverLabel + const defaultStorageDriver = + storageDriver === null + ? '' + : (currentStorageDriverLabel && allowedStorageDrivers[currentStorageDriverLabel] !== undefined + ? currentStorageDriverLabel + : undefined) ?? + Object.keys(allowedStorageDrivers)[0] ?? + '' + const formDefaultValues: CollectionFormData = { hostCollection: isEditingRootCollection ? null @@ -213,7 +270,7 @@ export const EditCreateCollectionForm = ({ type: onEditMode ? collection.type : '', contacts: defaultContacts, affiliation: onEditMode ? collection.affiliation ?? '' : user?.affiliation ?? '', - storage: 'S3', + storage: defaultStorageDriver, description: onEditMode ? collection.description ?? '' : '', [USE_FIELDS_FROM_PARENT]: useFieldsFromParentDefault, [METADATA_BLOCKS_NAMES_GROUPER]: defaultBlocksNames, @@ -231,6 +288,9 @@ export const EditCreateCollectionForm = ({ allFacetableMetadataFields={facetableMetadataFields} defaultCollectionFacets={defaultCollectionFacets} isEditingRootCollection={isEditingRootCollection} + canSelectStorageDriver={canSelectStorageDriver} + allowedStorageDrivers={allowedStorageDrivers} + inheritedOrDefaultStorageDriverName={effectiveStorageDriverDisplayName} /> ) } diff --git a/src/sections/shared/form/EditCreateCollectionForm/collection-form/CollectionForm.tsx b/src/sections/shared/form/EditCreateCollectionForm/collection-form/CollectionForm.tsx index 0cdd082cb..6baa631d4 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/collection-form/CollectionForm.tsx +++ b/src/sections/shared/form/EditCreateCollectionForm/collection-form/CollectionForm.tsx @@ -17,6 +17,7 @@ import { EditCreateCollectionFormMode } from '../EditCreateCollectionForm' import { RouteWithParams } from '@/sections/Route.enum' import { useCollectionRepositories } from '@/shared/contexts/repositories/RepositoriesProvider' import styles from './CollectionForm.module.scss' +import { AllowedStorageDrivers } from '@/collection/domain/models/AllowedStorageDrivers' export interface CollectionFormProps { mode: EditCreateCollectionFormMode @@ -26,6 +27,9 @@ export interface CollectionFormProps { allFacetableMetadataFields: MetadataField[] defaultCollectionFacets: CollectionFormFacet[] isEditingRootCollection: boolean + canSelectStorageDriver: boolean + allowedStorageDrivers: AllowedStorageDrivers + inheritedOrDefaultStorageDriverName?: string } export const CollectionForm = ({ @@ -35,7 +39,10 @@ export const CollectionForm = ({ allMetadataBlocksInfo, allFacetableMetadataFields, defaultCollectionFacets, - isEditingRootCollection + isEditingRootCollection, + canSelectStorageDriver, + allowedStorageDrivers, + inheritedOrDefaultStorageDriverName }: CollectionFormProps) => { const { collectionRepository } = useCollectionRepositories() const formContainerRef = useRef(null) @@ -53,7 +60,8 @@ export const CollectionForm = ({ collectionIdOrParentCollectionId, collectionRepository, onSubmittedCollectionError, - form.formState.dirtyFields + form.formState.dirtyFields, + canSelectStorageDriver ) function onSubmittedCollectionError() { @@ -92,7 +100,12 @@ export const CollectionForm = ({ onSubmit={form.handleSubmit(submitForm)} noValidate={true} data-testid="collection-form"> - + diff --git a/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/StorageField.tsx b/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/StorageField.tsx new file mode 100644 index 000000000..c7fb43788 --- /dev/null +++ b/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/StorageField.tsx @@ -0,0 +1,55 @@ +import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { Col, Form } from '@iqss/dataverse-design-system' +import { AllowedStorageDrivers } from '@/collection/domain/models/AllowedStorageDrivers' + +interface StorageFieldProps { + allowedStorageDrivers: AllowedStorageDrivers + inheritedOrDefaultStorageDriverName?: string +} + +export const StorageField = ({ + allowedStorageDrivers, + inheritedOrDefaultStorageDriverName +}: StorageFieldProps) => { + const { t } = useTranslation('shared', { keyPrefix: 'collectionForm' }) + const { control } = useFormContext() + const storageDriverOptions = Object.entries(allowedStorageDrivers) + + if (storageDriverOptions.length === 0) { + return null + } + + return ( + + + {t('fields.storage.label')} + + ( + + + + {storageDriverOptions.map(([driverLabel, displayName]) => ( + + ))} + + {error?.message} + + )} + /> + + ) +} diff --git a/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/TopFieldsSection.tsx b/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/TopFieldsSection.tsx index 2985417d8..a2cf07a44 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/TopFieldsSection.tsx +++ b/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/TopFieldsSection.tsx @@ -6,12 +6,22 @@ import { collectionTypeOptions } from '@/collection/domain/useCases/DTOs/Collect import { ContactsField } from './ContactsField' import { IdentifierField } from './IdentifierField' import { DescriptionField } from './DescriptionField' +import { StorageField } from './StorageField' +import { AllowedStorageDrivers } from '@/collection/domain/models/AllowedStorageDrivers' interface TopFieldsSectionProps { isEditingRootCollection: boolean + canSelectStorageDriver: boolean + allowedStorageDrivers: AllowedStorageDrivers + inheritedOrDefaultStorageDriverName?: string } -export const TopFieldsSection = ({ isEditingRootCollection }: TopFieldsSectionProps) => { +export const TopFieldsSection = ({ + isEditingRootCollection, + canSelectStorageDriver, + allowedStorageDrivers, + inheritedOrDefaultStorageDriverName +}: TopFieldsSectionProps) => { const { t } = useTranslation('shared', { keyPrefix: 'collectionForm' }) const { control } = useFormContext() @@ -152,34 +162,12 @@ export const TopFieldsSection = ({ isEditingRootCollection }: TopFieldsSectionPr - {/* 👇 To be defined, at the moment the SPA only supports file uploading through direct upload (S3), so we are disabling the storage selector */} - {/* - - {t('fields.storage.label')} - - ( - - - - {Object.values(collectionStorageOptions).map((type) => ( - - ))} - - {error?.message} - - )} + {canSelectStorageDriver && ( + - */} + )} {/* Category (type) & Email (contacts) & Description */} diff --git a/src/sections/shared/form/EditCreateCollectionForm/collection-form/useSubmitCollection.ts b/src/sections/shared/form/EditCreateCollectionForm/collection-form/useSubmitCollection.ts index 6adb44af9..44e566a17 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/collection-form/useSubmitCollection.ts +++ b/src/sections/shared/form/EditCreateCollectionForm/collection-form/useSubmitCollection.ts @@ -19,6 +19,8 @@ import { import { CollectionDTO } from '@/collection/domain/useCases/DTOs/CollectionDTO' import { createCollection } from '@/collection/domain/useCases/createCollection' import { editCollection } from '@/collection/domain/useCases/editCollection' +import { setCollectionDriver } from '@/collection/domain/useCases/setCollectionDriver' +import { deleteCollectionDriver } from '@/collection/domain/useCases/deleteCollectionDriver' import { RouteWithParams } from '@/sections/Route.enum' import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' import { CollectionFormHelper } from '../CollectionFormHelper' @@ -51,7 +53,8 @@ export function useSubmitCollection( collectionIdOrParentCollectionId: string, collectionRepository: CollectionRepository, onSubmitErrorCallback: () => void, - dirtyFields: CollectionFormDirtyFields + dirtyFields: CollectionFormDirtyFields, + canSelectStorageDriver: boolean ): UseSubmitCollectionReturnType { const navigate = useNavigate() const { t } = useTranslation('collection') @@ -101,13 +104,41 @@ export function useSubmitCollection( inheritFacetsFromParent: useFacetsFromParentChecked } + const shouldUpdateStorageDriver = canSelectStorageDriver && Boolean(dirtyFields.storage) + + const updateSelectedStorageDriver = (collectionIdOrAlias: number | string): Promise => { + if (!shouldUpdateStorageDriver) { + return Promise.resolve() + } + + if (!formData.storage) { + if (mode === 'create') { + return Promise.resolve() + } + + return deleteCollectionDriver(collectionRepository, collectionIdOrAlias).then( + () => undefined + ) + } + + return setCollectionDriver(collectionRepository, collectionIdOrAlias, formData.storage).then( + () => undefined + ) + } + if (mode === 'create') { createCollection( collectionRepository, newOrUpdatedCollection, collectionIdOrParentCollectionId ) - .then(() => { + .then(async (newCollectionIdentifier) => { + try { + await updateSelectedStorageDriver(newCollectionIdentifier) + } catch { + toast.error(t('storageDriverUpdateFailed')) + } + setSubmitError(null) setSubmissionStatus(SubmissionStatus.SubmitComplete) needsUpdateStore.setNeedsUpdate(true) @@ -126,6 +157,7 @@ export function useSubmitCollection( }) } else { editCollection(collectionRepository, newOrUpdatedCollection, collectionIdOrParentCollectionId) + .then(() => updateSelectedStorageDriver(collectionIdOrParentCollectionId)) .then(() => { setSubmitError(null) setSubmissionStatus(SubmissionStatus.SubmitComplete) diff --git a/src/sections/shared/form/EditCreateCollectionForm/types.ts b/src/sections/shared/form/EditCreateCollectionForm/types.ts index 9f4c04e9b..c07b40389 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/types.ts +++ b/src/sections/shared/form/EditCreateCollectionForm/types.ts @@ -1,5 +1,4 @@ import { type FieldNamesMarkedBoolean } from 'react-hook-form' -import { CollectionStorage } from '@/collection/domain/useCases/DTOs/CollectionDTO' import { MetadataBlockInfo, MetadataField @@ -17,7 +16,7 @@ export type CollectionFormData = { name: string affiliation: string alias: string - storage: CollectionStorage + storage: string type: CollectionType | '' description: string contacts: { value: string }[] diff --git a/src/shared/hooks/useGetCollectionAllowedStorageDrivers.ts b/src/shared/hooks/useGetCollectionAllowedStorageDrivers.ts new file mode 100644 index 000000000..de7121413 --- /dev/null +++ b/src/shared/hooks/useGetCollectionAllowedStorageDrivers.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react' +import { AllowedStorageDrivers } from '@/collection/domain/models/AllowedStorageDrivers' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { getCollectionAllowedStorageDrivers } from '@/collection/domain/useCases/getCollectionAllowedStorageDrivers' + +interface Props { + collectionIdOrAlias: number | string + collectionRepository: CollectionRepository + enabled?: boolean +} + +interface UseGetCollectionAllowedStorageDriversReturn { + allowedStorageDrivers: AllowedStorageDrivers + error: string | null + isLoading: boolean +} + +export const useGetCollectionAllowedStorageDrivers = ({ + collectionIdOrAlias, + collectionRepository, + enabled = true +}: Props): UseGetCollectionAllowedStorageDriversReturn => { + const [allowedStorageDrivers, setAllowedStorageDrivers] = useState({}) + const [isLoading, setIsLoading] = useState(enabled) + const [error, setError] = useState(null) + + useEffect(() => { + if (!enabled) { + setAllowedStorageDrivers({}) + setIsLoading(false) + setError(null) + return + } + + const handleGetCollectionAllowedStorageDrivers = async () => { + setIsLoading(true) + try { + const allowedStorageDrivers = await getCollectionAllowedStorageDrivers( + collectionRepository, + collectionIdOrAlias + ) + + setAllowedStorageDrivers(allowedStorageDrivers) + setError(null) + } catch (err) { + const errorMessage = + err instanceof Error && err.message + ? err.message + : 'Something went wrong getting the allowed storage drivers for this collection. Try again later.' + setError(errorMessage) + } finally { + setIsLoading(false) + } + } + + void handleGetCollectionAllowedStorageDrivers() + }, [collectionIdOrAlias, collectionRepository, enabled]) + + return { + allowedStorageDrivers, + error, + isLoading + } +} diff --git a/src/shared/hooks/useGetCollectionStorageDriver.ts b/src/shared/hooks/useGetCollectionStorageDriver.ts new file mode 100644 index 000000000..d21a90c8a --- /dev/null +++ b/src/shared/hooks/useGetCollectionStorageDriver.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { StorageDriver } from '@/collection/domain/models/StorageDriver' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { getCollectionStorageDriver } from '@/collection/domain/useCases/getCollectionStorageDriver' + +interface Props { + collectionIdOrAlias: number | string + collectionRepository: CollectionRepository + enabled?: boolean + getEffective?: boolean +} + +interface UseGetCollectionStorageDriverReturn { + storageDriver: StorageDriver | null + error: string | null + isLoading: boolean +} + +export const useGetCollectionStorageDriver = ({ + collectionIdOrAlias, + collectionRepository, + enabled = true, + getEffective +}: Props): UseGetCollectionStorageDriverReturn => { + const [storageDriver, setStorageDriver] = useState(null) + const [isLoading, setIsLoading] = useState(enabled) + const [error, setError] = useState(null) + + useEffect(() => { + if (!enabled) { + setStorageDriver(null) + setIsLoading(false) + setError(null) + return + } + + const handleGetCollectionStorageDriver = async () => { + setIsLoading(true) + try { + const storageDriver = await getCollectionStorageDriver( + collectionRepository, + collectionIdOrAlias, + getEffective + ) + + setStorageDriver(storageDriver ?? null) + setError(null) + } catch (err) { + const errorMessage = + err instanceof Error && err.message + ? err.message + : 'Something went wrong getting the storage driver for this collection. Try again later.' + setError(errorMessage) + } finally { + setIsLoading(false) + } + } + + void handleGetCollectionStorageDriver() + }, [collectionIdOrAlias, collectionRepository, enabled, getEffective]) + + return { + storageDriver, + error, + isLoading + } +} diff --git a/src/stories/collection/CollectionMockRepository.ts b/src/stories/collection/CollectionMockRepository.ts index 5406ff8ed..b3544e0db 100644 --- a/src/stories/collection/CollectionMockRepository.ts +++ b/src/stories/collection/CollectionMockRepository.ts @@ -19,6 +19,8 @@ import { CollectionSummary } from '@/collection/domain/models/CollectionSummary' import { LinkingObjectType } from '@/collection/domain/useCases/getCollectionsForLinking' import { CollectionSummaryMother } from '@tests/component/collection/domain/models/CollectionSummaryMother' import { CollectionLinks } from '@/collection/domain/models/CollectionLinks' +import { AllowedStorageDrivers } from '@/collection/domain/models/AllowedStorageDrivers' +import { StorageDriver } from '@/collection/domain/models/StorageDriver' export class CollectionMockRepository implements CollectionRepository { getById(_id?: string): Promise { @@ -249,4 +251,46 @@ export class CollectionMockRepository implements CollectionRepository { }, FakerHelper.loadingTimout()) }) } + + getAllowedStorageDrivers(_collectionIdOrAlias: number | string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ S3: 'S3' }) + }, FakerHelper.loadingTimout()) + }) + } + + getStorageDriver( + _collectionIdOrAlias: number | string, + _getEffective?: boolean + ): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + name: 's3', + type: 's3', + label: 'S3', + directUpload: true, + directDownload: true, + uploadOutOfBand: false + }) + }, FakerHelper.loadingTimout()) + }) + } + + setStorageDriver(_collectionIdOrAlias: number | string, _driverLabel: string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve('Storage driver updated.') + }, FakerHelper.loadingTimout()) + }) + } + + deleteStorageDriver(_collectionIdOrAlias: number | string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve('Storage driver deleted.') + }, FakerHelper.loadingTimout()) + }) + } } diff --git a/src/stories/create-collection/CreateCollection.stories.tsx b/src/stories/create-collection/CreateCollection.stories.tsx index 2628eeb39..48256393a 100644 --- a/src/stories/create-collection/CreateCollection.stories.tsx +++ b/src/stories/create-collection/CreateCollection.stories.tsx @@ -11,7 +11,9 @@ import { FakerHelper } from '../../../tests/component/shared/FakerHelper' import { MetadataBlockInfoMockRepository } from '../shared-mock-repositories/metadata-block-info/MetadataBlockInfoMockRepository' import { MetadataBlockInfoMockLoadingRepository } from '../shared-mock-repositories/metadata-block-info/MetadataBlockInfoMockLoadingRepository' import { MetadataBlockInfoMockErrorRepository } from '../shared-mock-repositories/metadata-block-info/MetadataBlockInfoMockErrorRepository' -import { WithRepositories } from '../WithRepositories' +import { RepositoriesStoryProvider, WithRepositories } from '../WithRepositories' +import { SessionContext } from '@/sections/session/SessionContext' +import { UserMother } from '@tests/component/users/domain/models/UserMother' import { ROOT_COLLECTION_ALIAS } from '@tests/e2e-integration/shared/collection/ROOT_COLLECTION_ALIAS' @@ -36,6 +38,70 @@ export const Default: Story = { /> ) } + +const collectionRepositoryWithStorageDrivers = new CollectionMockRepository() +collectionRepositoryWithStorageDrivers.getAllowedStorageDrivers = () => { + return Promise.resolve({ + s3: 's3', + file1: 'FileSystem' + }) +} +collectionRepositoryWithStorageDrivers.getStorageDriver = () => { + return Promise.resolve({ + name: 's3', + type: 's3', + label: 's3', + directUpload: true, + directDownload: true, + uploadOutOfBand: false + }) +} + +const collectionRepositoryWithInheritedStorageDriver = new CollectionMockRepository() +collectionRepositoryWithInheritedStorageDriver.getAllowedStorageDrivers = () => { + return Promise.resolve({ + s3: 's3', + file1: 'FileSystem' + }) +} +collectionRepositoryWithInheritedStorageDriver.getStorageDriver = ( + _collectionIdOrAlias, + getEffective +) => { + if (!getEffective) { + return Promise.resolve(undefined) + } + + return Promise.resolve({ + name: 's3', + type: 's3', + label: 's3', + directUpload: true, + directDownload: true, + uploadOutOfBand: false + }) +} + +export const SuperUserWithStorageDriver: Story = { + render: () => ( + {}, + isLoadingUser: false, + sessionError: null, + refetchUserSession: () => Promise.resolve() + }}> + + + + + ) +} + export const Loading: Story = { decorators: [WithRepositories({ collectionRepository: new CollectionLoadingMockRepository() })], render: () => ( diff --git a/src/stories/edit-collection/EditCollection.stories.tsx b/src/stories/edit-collection/EditCollection.stories.tsx index 177b6c32a..31cc9a290 100644 --- a/src/stories/edit-collection/EditCollection.stories.tsx +++ b/src/stories/edit-collection/EditCollection.stories.tsx @@ -14,6 +14,8 @@ import { MetadataBlockInfoMockRepository } from '../shared-mock-repositories/met import { MetadataBlockInfoMockLoadingRepository } from '../shared-mock-repositories/metadata-block-info/MetadataBlockInfoMockLoadingRepository' import { MetadataBlockInfoMockErrorRepository } from '../shared-mock-repositories/metadata-block-info/MetadataBlockInfoMockErrorRepository' import { RepositoriesStoryProvider, WithRepositories } from '../WithRepositories' +import { SessionContext } from '@/sections/session/SessionContext' +import { UserMother } from '@tests/component/users/domain/models/UserMother' const meta: Meta = { title: 'Pages/Edit Collection', @@ -27,32 +29,38 @@ const meta: Meta = { export default meta type Story = StoryObj -export const Default: Story = { - render: () => { - const collectionRepo = new CollectionMockRepository() - collectionRepo.getById = () => { - return new Promise((resolve) => { - setTimeout(() => { - resolve( - CollectionMother.create({ +const createScienceCollectionRepository = () => { + const collectionRepo = new CollectionMockRepository() + collectionRepo.getById = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve( + CollectionMother.create({ + id: 'science', + isReleased: true, + name: 'Collection Name', + description: 'We do all the science.', + affiliation: 'Scientific Research University', + hierarchy: UpwardHierarchyNodeMother.createCollection({ id: 'science', - isReleased: true, name: 'Collection Name', - description: 'We do all the science.', - affiliation: 'Scientific Research University', - hierarchy: UpwardHierarchyNodeMother.createCollection({ - id: 'science', - name: 'Collection Name', - parent: UpwardHierarchyNodeMother.createCollection({ - id: ROOT_COLLECTION_ALIAS, - name: 'Root' - }) + parent: UpwardHierarchyNodeMother.createCollection({ + id: ROOT_COLLECTION_ALIAS, + name: 'Root' }) }) - ) - }, FakerHelper.loadingTimout()) - }) - } + }) + ) + }, FakerHelper.loadingTimout()) + }) + } + + return collectionRepo +} + +export const Default: Story = { + render: () => { + const collectionRepo = createScienceCollectionRepository() return ( @@ -65,6 +73,50 @@ export const Default: Story = { } } +export const SuperUserWithStorageDriver: Story = { + render: () => { + const collectionRepo = createScienceCollectionRepository() + collectionRepo.getAllowedStorageDrivers = () => { + return Promise.resolve({ + s3: 's3', + file1: 'FileSystem' + }) + } + collectionRepo.getStorageDriver = (_collectionIdOrAlias, getEffective) => { + if (!getEffective) { + return Promise.resolve(undefined) + } + + return Promise.resolve({ + name: 's3', + type: 's3', + label: 's3', + directUpload: true, + directDownload: true, + uploadOutOfBand: false + }) + } + + return ( + {}, + isLoadingUser: false, + sessionError: null, + refetchUserSession: () => Promise.resolve() + }}> + + + + + ) + } +} + export const EditingRoot: Story = { decorators: [WithRepositories({ collectionRepository: new CollectionMockRepository() })], render: () => ( diff --git a/tests/component/sections/create-collection/CreateCollection.spec.tsx b/tests/component/sections/create-collection/CreateCollection.spec.tsx index c6a7cd033..6925431a8 100644 --- a/tests/component/sections/create-collection/CreateCollection.spec.tsx +++ b/tests/component/sections/create-collection/CreateCollection.spec.tsx @@ -54,7 +54,7 @@ describe('CreateCollection', () => { return Cypress.Promise.delay(DELAYED_TIME).then(() => collection) }) - cy.customMount( + cy.mountAuthenticated( { }) it('should render the correct breadcrumbs', () => { - cy.customMount( + cy.mountAuthenticated( { it('should show page not found when owner collection does not exist', () => { collectionRepository.getById = cy.stub().resolves(null) - cy.customMount( + cy.mountAuthenticated( { cy.findByRole('button', { name: 'Edit Dataset' }).click() cy.findByRole('button', { name: 'Private URL' }).should('exist') }) + + it('renders the Edit Private URL if user can manage dataset permissions', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.create({ + canUpdateDataset: true, + canManageDatasetPermissions: true, + canManageFilesPermissions: false + }), + locks: [], + hasValidTermsOfAccess: true + }) + + cy.mountAuthenticated( + + ) + + cy.findByRole('button', { name: 'Edit Dataset' }).click() + cy.findByRole('button', { name: 'Private URL' }).should('exist') + }) + + it('does not render permissions or private URL options if user cannot manage dataset or file permissions', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.create({ + canUpdateDataset: true, + canManageDatasetPermissions: false, + canManageFilesPermissions: false + }), + locks: [], + hasValidTermsOfAccess: true + }) + + cy.mountAuthenticated( + + ) + + cy.findByRole('button', { name: 'Edit Dataset' }).click() + cy.findByRole('button', { name: 'Permissions' }).should('not.exist') + cy.findByRole('button', { name: 'Private URL' }).should('not.exist') + }) + + it('renders only the dataset permissions submenu item if user can manage dataset permissions', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.create({ + canUpdateDataset: true, + canManageDatasetPermissions: true, + canManageFilesPermissions: false + }), + locks: [], + hasValidTermsOfAccess: true + }) + + cy.mountAuthenticated( + + ) + + cy.findByRole('button', { name: 'Edit Dataset' }).click() + cy.findByRole('button', { name: 'Permissions' }).click() + cy.findByRole('button', { name: 'Dataset' }).should('exist') + cy.findByRole('button', { name: 'File' }).should('not.exist') + }) + + it('renders only the file permissions submenu item if user can manage file permissions', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.create({ + canUpdateDataset: true, + canManageDatasetPermissions: false, + canManageFilesPermissions: true + }), + locks: [], + hasValidTermsOfAccess: true + }) + + cy.mountAuthenticated( + + ) + + cy.findByRole('button', { name: 'Edit Dataset' }).click() + cy.findByRole('button', { name: 'Permissions' }).click() + cy.findByRole('button', { name: 'Dataset' }).should('not.exist') + cy.findByRole('button', { name: 'File' }).should('exist') + }) }) diff --git a/tests/component/sections/edit-collection/EditCollection.spec.tsx b/tests/component/sections/edit-collection/EditCollection.spec.tsx index 0051c6bf3..745fa8b8a 100644 --- a/tests/component/sections/edit-collection/EditCollection.spec.tsx +++ b/tests/component/sections/edit-collection/EditCollection.spec.tsx @@ -54,7 +54,7 @@ describe('EditCollection', () => { return Cypress.Promise.delay(DELAYED_TIME).then(() => collection) }) - cy.customMount( + cy.mountAuthenticated( { }) it('should render the correct breadcrumbs', () => { - cy.customMount( + cy.mountAuthenticated( { it('should show page not found when collection does not exist', () => { collectionRepository.getById = cy.stub().resolves(null) - cy.customMount( + cy.mountAuthenticated( = {}): MetadataField => ({ + name: 'field', + displayName: 'Field', + title: 'Field', + type: 'TEXT', + typeClass: 'primitive', + watermark: '', + description: '', + multiple: false, + isControlledVocabulary: false, + displayFormat: '', + isRequired: false, + displayOnCreate: true, + displayOrder: 0, + isAdvancedSearchFieldType: false, + ...overrides +}) + const metadataBlocksInfo: MetadataBlockInfo[] = [ { id: 10, @@ -1539,29 +1557,176 @@ describe('MetadataFieldsHelper', () => { }) }) - describe('buildTemplateFieldsFromMetadataValues', () => { - const buildField = (overrides: Partial): MetadataField => ({ - name: 'field', - displayName: 'Field', - title: 'Field', - type: 'TEXT', - typeClass: 'primitive', - watermark: '', - description: '', - multiple: false, - isControlledVocabulary: false, - displayFormat: '', - isRequired: false, - displayOnCreate: true, - displayOrder: 0, - isAdvancedSearchFieldType: false, - ...overrides + describe('defineMetadataBlockInfo', () => { + const createBlocks: MetadataBlockInfo[] = [ + { + id: 1, + name: 'citation', + displayName: 'Citation', + displayOnCreate: true, + metadataFields: { + title: buildMetadataField({ + name: 'title', + displayName: 'Title', + title: 'Title', + displayOrder: 2 + }), + subject: buildMetadataField({ + name: 'subject', + displayName: 'Subject', + title: 'Subject', + typeClass: 'controlledVocabulary', + isControlledVocabulary: true, + controlledVocabularyValues: ['Medicine'], + displayOrder: 1 + }) + } + } + ] + + const editBlocks: MetadataBlockInfo[] = [ + { + ...createBlocks[0], + metadataFields: { + ...createBlocks[0].metadataFields, + 'producer.name': buildMetadataField({ + name: 'producer.name', + displayName: 'Producer', + title: 'Producer', + displayOnCreate: false, + displayOrder: 3 + }) + } + }, + { + id: 2, + name: 'geo', + displayName: 'Geospatial', + displayOnCreate: false, + metadataFields: { + 'bounding.box': buildMetadataField({ + name: 'bounding.box', + displayName: 'Bounding Box', + title: 'Bounding Box', + displayOnCreate: false, + displayOrder: 1 + }) + } + } + ] + + it('returns normalized create metadata blocks when no template is provided', () => { + const result = MetadataFieldsHelper.defineMetadataBlockInfo( + 'create', + createBlocks, + editBlocks, + undefined, + undefined + ) + + expect(result).to.deep.equal(createBlocks) }) + it('adds template-only fields and blocks from edit metadata in create mode', () => { + const result = MetadataFieldsHelper.defineMetadataBlockInfo( + 'create', + createBlocks, + editBlocks, + undefined, + [ + { + name: 'citation', + fields: { + 'producer.name': 'Ada Lovelace', + unknownField: 'Ignored' + } + }, + { + name: 'geo', + fields: { + 'bounding.box': '10,20,30,40' + } + }, + { + name: 'missingFromEdit', + fields: { + field: 'Ignored' + } + }, + { + name: 'emptyTemplateBlock', + fields: {} + } + ] + ) + + const citationFields = result.find((block) => block.name === 'citation')?.metadataFields + const geoFields = result.find((block) => block.name === 'geo')?.metadataFields + + expect(Object.keys(citationFields ?? {})).to.deep.equal(['subject', 'title', 'producer/name']) + expect(citationFields?.['producer/name']).to.include({ + name: 'producer/name', + value: 'Ada Lovelace' + }) + expect(citationFields).not.to.have.property('unknownField') + expect(geoFields?.['bounding/box']).to.include({ + name: 'bounding/box', + value: '10,20,30,40' + }) + expect(result.map((block) => block.name)).to.deep.equal(['citation', 'geo']) + }) + + it('adds current dataset values and orders fields in edit mode', () => { + const result = MetadataFieldsHelper.defineMetadataBlockInfo( + 'edit', + createBlocks, + editBlocks, + [ + { + name: 'citation', + fields: { + title: 'Dataset title', + subject: ['Medicine'], + 'producer.name': 'Grace Hopper' + } + } + ] as unknown as DatasetMetadataBlocks, + undefined + ) + + const fields = (result as MetadataBlockInfoWithMaybeValues[])[0].metadataFields + + expect(Object.keys(fields)).to.deep.equal(['subject', 'title', 'producer/name']) + expect(fields.title.value).to.equal('Dataset title') + expect(fields.subject.value).to.deep.equal(['Medicine']) + expect(fields['producer/name'].value).to.equal('Grace Hopper') + }) + }) + + describe('addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate', () => { + it('returns the create metadata blocks when the template has no fields to add', () => { + expect( + MetadataFieldsHelper.addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate( + metadataBlocksInfo, + normalizedMetadataBlocksInfo, + undefined + ) + ).to.equal(metadataBlocksInfo) + expect( + MetadataFieldsHelper.addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate( + metadataBlocksInfo, + normalizedMetadataBlocksInfo, + [] + ) + ).to.equal(metadataBlocksInfo) + }) + }) + + describe('buildTemplateFieldsFromMetadataValues', () => { it('builds template fields with primitive, vocabulary, and compound values', () => { const metadataFields: Record = { - title: buildField({ name: 'title', displayName: 'Title', title: 'Title' }), - subject: buildField({ + title: buildMetadataField({ name: 'title', displayName: 'Title', title: 'Title' }), + subject: buildMetadataField({ name: 'subject', displayName: 'Subject', title: 'Subject', @@ -1570,7 +1735,7 @@ describe('MetadataFieldsHelper', () => { multiple: true, controlledVocabularyValues: ['A', 'B'] }), - author: buildField({ + author: buildMetadataField({ name: 'author', displayName: 'Author', title: 'Author', @@ -1578,19 +1743,23 @@ describe('MetadataFieldsHelper', () => { typeClass: 'compound', multiple: true, childMetadataFields: { - authorName: buildField({ + authorName: buildMetadataField({ name: 'authorName', displayName: 'Author Name', title: 'Name' }), - authorAffiliation: buildField({ + authorAffiliation: buildMetadataField({ name: 'authorAffiliation', displayName: 'Author Affiliation', title: 'Affiliation' }) } }), - emptyField: buildField({ name: 'emptyField', displayName: 'Empty Field', title: 'Empty' }) + emptyField: buildMetadataField({ + name: 'emptyField', + displayName: 'Empty Field', + title: 'Empty' + }) } const fieldValues: DatasetMetadataFieldsDTO = { @@ -1652,6 +1821,128 @@ describe('MetadataFieldsHelper', () => { } ]) }) + + it('ignores unknown, empty, and malformed template field values', () => { + const metadataFields: Record = { + keywords: buildMetadataField({ + name: 'keywords', + displayName: 'Keywords', + title: 'Keywords', + multiple: true + }), + author: buildMetadataField({ + name: 'author', + displayName: 'Author', + title: 'Author', + type: 'NONE', + typeClass: 'compound', + childMetadataFields: { + authorName: buildMetadataField({ name: 'authorName' }), + authorAffiliation: buildMetadataField({ name: 'authorAffiliation' }) + } + }), + notes: buildMetadataField({ name: 'notes' }) + } + + const templateFields = MetadataFieldsHelper.buildTemplateFieldsFromMetadataValues( + { + keywords: ['valid', { invalid: true }], + author: [ + null, + ['invalid'], + { missingChild: 'Ignored' }, + { authorName: '', authorAffiliation: undefined }, + { authorName: 'Ada', authorAffiliation: 'Math' } + ], + notes: null, + unknown: 'Ignored' + } as unknown as DatasetMetadataFieldsDTO, + metadataFields + ) + + expect(templateFields).to.deep.equal([ + { + typeName: 'author', + multiple: false, + typeClass: 'compound', + value: { + authorName: { + value: 'Ada', + typeName: 'authorName', + multiple: false, + typeClass: 'primitive' + }, + authorAffiliation: { + value: 'Math', + typeName: 'authorAffiliation', + multiple: false, + typeClass: 'primitive' + } + } + } + ]) + }) + }) + + describe('formatFormValuesToDatasetDTO', () => { + it('omits empty composed values in create mode and keeps them in edit mode', () => { + const formValues: DatasetMetadataFormValues = { + citation: { + title: '', + subjects: [], + keyword: [{ value: '' }], + author: { authorName: '', authorAffiliation: '' }, + grant: [{ grantNumber: '' }] + } + } + + expect(MetadataFieldsHelper.formatFormValuesToDatasetDTO(formValues, 'create')).to.deep.equal( + { + licence: defaultLicense, + metadataBlocks: [ + { + name: 'citation', + fields: {} + } + ] + } + ) + expect(MetadataFieldsHelper.formatFormValuesToDatasetDTO(formValues, 'edit')).to.deep.equal({ + licence: defaultLicense, + metadataBlocks: [ + { + name: 'citation', + fields: { + title: '', + subjects: [], + keyword: [], + author: { + authorName: '', + authorAffiliation: '' + }, + grant: [ + { + grantNumber: '' + } + ] + } + } + ] + }) + }) + }) + + describe('getValidationFailedFieldError', () => { + it('extracts validation failure text and returns null for unrelated errors', () => { + expect( + MetadataFieldsHelper.getValidationFailedFieldError( + 'Validation Failed: Point of Contact E-mail test@test.c is not valid. (Invalid value:abc)' + ) + ).to.equal('Point of Contact E-mail test@test.c is not valid.') + expect(MetadataFieldsHelper.getValidationFailedFieldError('Something else failed')).to.equal( + null + ) + }) }) describe('isValidDateFormat', () => { @@ -1771,4 +2062,60 @@ describe('MetadataFieldsHelper', () => { }) }) }) + + describe('date validation helpers', () => { + it('validates date patterns directly', () => { + expect(MetadataFieldsHelper.isValidDateAgainstPattern('', 'yyyy')).to.equal(false) + expect(MetadataFieldsHelper.isValidDateAgainstPattern('9999', 'yyyy')).to.equal(true) + expect(MetadataFieldsHelper.isValidDateAgainstPattern('10000', 'yyyy')).to.equal(false) + expect(MetadataFieldsHelper.isValidDateAgainstPattern('2023-12', 'yyyy-MM')).to.equal(true) + expect(MetadataFieldsHelper.isValidDateAgainstPattern('2023-00', 'yyyy-MM')).to.equal(false) + expect(MetadataFieldsHelper.isValidDateAgainstPattern('2020-02-29', 'yyyy-MM-dd')).to.equal( + true + ) + expect(MetadataFieldsHelper.isValidDateAgainstPattern('2021-02-29', 'yyyy-MM-dd')).to.equal( + false + ) + expect( + MetadataFieldsHelper.isValidDateAgainstPattern( + '2023-11-30T23:59:59', + "yyyy-MM-dd'T'HH:mm:ss" + ) + ).to.equal(true) + expect( + MetadataFieldsHelper.isValidDateAgainstPattern( + '2023-11-30T24:00:00', + "yyyy-MM-dd'T'HH:mm:ss" + ) + ).to.equal(false) + expect( + MetadataFieldsHelper.isValidDateAgainstPattern( + '2023-11-30T23:59:59.123', + "yyyy-MM-dd'T'HH:mm:ss.SSS" + ) + ).to.equal(true) + expect( + MetadataFieldsHelper.isValidDateAgainstPattern( + '2023-11-30T23:59:59.12', + "yyyy-MM-dd'T'HH:mm:ss.SSS" + ) + ).to.equal(false) + expect( + MetadataFieldsHelper.isValidDateAgainstPattern('2023-11-30 23:59:59', 'yyyy-MM-dd HH:mm:ss') + ).to.equal(true) + expect(MetadataFieldsHelper.isValidDateAgainstPattern('2023', 'unsupported')).to.equal(false) + }) + + it('validates time and leap-year helpers directly', () => { + expect(MetadataFieldsHelper.isValidHMS('23', '59', '59')).to.equal(true) + expect(MetadataFieldsHelper.isValidHMS('24', '00', '00')).to.equal(false) + expect(MetadataFieldsHelper.isValidHMS('23', '60', '00')).to.equal(false) + expect(MetadataFieldsHelper.isValidHMS('23', '59', '60')).to.equal(false) + expect(MetadataFieldsHelper.daysInMonth(2024, 2)).to.equal(29) + expect(MetadataFieldsHelper.daysInMonth(2023, 2)).to.equal(28) + expect(MetadataFieldsHelper.daysInMonth(2023, 13)).to.equal(0) + expect(MetadataFieldsHelper.isLeapYear(2000)).to.equal(true) + expect(MetadataFieldsHelper.isLeapYear(1900)).to.equal(false) + }) + }) }) diff --git a/tests/component/sections/shared/edit-create-collection-form/EditCreateCollectionForm.spec.tsx b/tests/component/sections/shared/edit-create-collection-form/EditCreateCollectionForm.spec.tsx index e252eb786..8675bf5cc 100644 --- a/tests/component/sections/shared/edit-create-collection-form/EditCreateCollectionForm.spec.tsx +++ b/tests/component/sections/shared/edit-create-collection-form/EditCreateCollectionForm.spec.tsx @@ -19,6 +19,7 @@ const metadataBlockInfoRepository = {} as MetadataBlockInfoRepository const userRepository: UserRepository = {} as UserRepository const testUser = UserMother.create() +const testSuperUser = UserMother.createSuperUser() const PARENT_COLLECTION_ID = 'root' const PARENT_COLLECTION_NAME = 'Root' @@ -81,6 +82,20 @@ const collectionFacets = CollectionFacetMother.createFacets() const allFacetableMetadataFields = MetadataBlockInfoMother.getAllFacetableMetadataFields() +const allowedStorageDrivers = { + s3: 's3', + swift: 'Swift' +} + +const s3StorageDriver = { + name: 's3', + type: 's3', + label: 's3', + directUpload: true, + directDownload: true, + uploadOutOfBand: false +} + function EditCreateCollectionForm({ collectionRepository, ...props @@ -99,6 +114,10 @@ describe('EditCreateCollectionForm', () => { collectionRepository.create = cy.stub().resolves(1) collectionRepository.edit = cy.stub().resolves({}) collectionRepository.getFacets = cy.stub().resolves(collectionFacets) + collectionRepository.getAllowedStorageDrivers = cy.stub().resolves(allowedStorageDrivers) + collectionRepository.getStorageDriver = cy.stub().resolves(s3StorageDriver) + collectionRepository.setStorageDriver = cy.stub().resolves('Storage driver updated.') + collectionRepository.deleteStorageDriver = cy.stub().resolves('Storage driver deleted.') userRepository.getAuthenticated = cy.stub().resolves(testUser) metadataBlockInfoRepository.getByCollectionId = cy.stub().resolves(colllectionMetadataBlocks) metadataBlockInfoRepository.getAll = cy.stub().resolves(allMetadataBlocksMock) @@ -156,6 +175,70 @@ describe('EditCreateCollectionForm', () => { cy.findByLabelText(/^Email/i).should('have.value', testUser.email) }) + it('does not show the storage field when the current user is not a superuser', () => { + cy.mountAuthenticated( + + ) + + cy.findByTestId('collection-form').should('exist') + cy.get('body').should('not.contain', 'Storage') + }) + + it('shows the allowed storage drivers when the current user is a superuser', () => { + cy.mountAuthenticated( + , + undefined, + { superuser: true } + ) + + cy.findByLabelText(/^Storage/i).should('have.value', 's3') + cy.findByRole('option', { + name: 'Use inherited or default storage driver (s3)' + }).should('exist') + cy.findByRole('option', { name: 's3' }).should('exist') + cy.findByRole('option', { name: 'Swift' }).should('exist') + }) + + it('shows the inherited or default storage driver option when creating under a collection without an explicit storage driver', () => { + const getStorageDriver = cy.stub().as('getStorageDriver') + getStorageDriver.withArgs(PARENT_COLLECTION_ID, false).resolves(undefined) + getStorageDriver.withArgs(PARENT_COLLECTION_ID, true).resolves(s3StorageDriver) + collectionRepository.getStorageDriver = getStorageDriver + + cy.mountAuthenticated( + , + undefined, + { superuser: true } + ) + + cy.findByLabelText(/^Storage/i).should('have.value', '') + cy.findByRole('option', { + name: 'Use inherited or default storage driver (s3)' + }).should('exist') + cy.findByRole('option', { name: 's3' }).should('exist') + cy.findByRole('option', { name: 'Swift' }).should('exist') + cy.get('@getStorageDriver').should('have.been.calledWith', PARENT_COLLECTION_ID, false) + cy.get('@getStorageDriver').should('have.been.calledWith', PARENT_COLLECTION_ID, true) + }) + it('submit button should be disabled when form has not been touched', () => { cy.customMount( { cy.get('@setNeedsUpdate').should('have.been.calledWith', true) }) + it('sets the selected storage driver after creating a collection as a superuser', () => { + collectionRepository.setStorageDriver = cy.stub().as('setStorageDriver').resolves() + + cy.mountAuthenticated( + , + undefined, + { superuser: true } + ) + + cy.findByRole('button', { name: 'Apply suggestion' }).click() + cy.findByLabelText(/^Category/i).select(1) + cy.findByLabelText(/^Storage/i).select('swift') + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.get('@setStorageDriver').should('have.been.calledWith', 1, 'swift') + }) + + it('does not fail collection creation when setting the selected storage driver fails', () => { + cy.spy(needsUpdateStore, 'setNeedsUpdate').as('setNeedsUpdate') + collectionRepository.setStorageDriver = cy + .stub() + .as('setStorageDriver') + .rejects(new Error('Error setting storage driver')) + + cy.mountAuthenticated( + , + undefined, + { superuser: true } + ) + + cy.findByRole('button', { name: 'Apply suggestion' }).click() + cy.findByLabelText(/^Category/i).select(1) + cy.findByLabelText(/^Storage/i).select('swift') + + cy.findByRole('button', { name: 'Create Collection' }).click() + + cy.get('@setStorageDriver').should('have.been.calledWith', 1, 'swift') + cy.findByText('Error').should('not.exist') + cy.findByText('Success!').should('exist') + cy.findByText( + 'The collection was created, but the storage driver could not be updated.' + ).should('exist') + cy.get('@setNeedsUpdate').should('have.been.calledWith', true) + }) + it('submits a valid form and fails', () => { collectionRepository.create = cy.stub().rejects(new Error('Error creating collection')) @@ -1153,6 +1294,87 @@ describe('EditCreateCollectionForm', () => { cy.findByText('Success!').should('exist') }) + it('sets the selected storage driver after editing a collection as a superuser', () => { + collectionRepository.setStorageDriver = cy.stub().as('setStorageDriver').resolves() + collectionRepository.deleteStorageDriver = cy.stub().as('deleteStorageDriver').resolves() + + cy.mountAuthenticated( + , + undefined, + { superuser: true } + ) + + cy.findByLabelText(/^Storage/i).select('swift') + cy.findByRole('button', { name: 'Save Changes' }).click() + + cy.get('@setStorageDriver').should( + 'have.been.calledWith', + COLLECTION_BEING_EDITED_ID, + 'swift' + ) + cy.get('@deleteStorageDriver').should('not.have.been.called') + }) + + it('deletes the explicit storage driver after editing a collection to use the inherited or default storage driver', () => { + collectionRepository.setStorageDriver = cy.stub().as('setStorageDriver').resolves() + collectionRepository.deleteStorageDriver = cy.stub().as('deleteStorageDriver').resolves() + + cy.mountAuthenticated( + , + undefined, + { superuser: true } + ) + + cy.findByLabelText(/^Storage/i).select('') + cy.findByRole('button', { name: 'Save Changes' }).click() + + cy.get('@deleteStorageDriver').should('have.been.calledWith', COLLECTION_BEING_EDITED_ID) + cy.get('@setStorageDriver').should('not.have.been.called') + }) + + it('shows the inherited or default storage driver option when editing a collection without an explicit storage driver', () => { + const getStorageDriver = cy.stub().as('getStorageDriver') + getStorageDriver.withArgs(COLLECTION_BEING_EDITED_ID, false).resolves(undefined) + getStorageDriver.withArgs(COLLECTION_BEING_EDITED_ID, true).resolves(s3StorageDriver) + collectionRepository.getStorageDriver = getStorageDriver + + cy.mountAuthenticated( + , + undefined, + { superuser: true } + ) + + cy.findByLabelText(/^Storage/i).should('have.value', '') + cy.findByRole('option', { + name: 'Use inherited or default storage driver (s3)' + }).should('exist') + cy.findByRole('option', { name: 's3' }).should('exist') + cy.findByRole('option', { name: 'Swift' }).should('exist') + cy.get('@getStorageDriver').should('have.been.calledWith', COLLECTION_BEING_EDITED_ID, false) + cy.get('@getStorageDriver').should('have.been.calledWith', COLLECTION_BEING_EDITED_ID, true) + }) + it('submits a valid form and fails', () => { collectionRepository.edit = cy.stub().rejects(new Error('Error editing collection')) diff --git a/tests/component/sections/shared/pagination/PaginationControls.spec.tsx b/tests/component/sections/shared/pagination/PaginationControls.spec.tsx index a7a5d8071..fa9b93f03 100644 --- a/tests/component/sections/shared/pagination/PaginationControls.spec.tsx +++ b/tests/component/sections/shared/pagination/PaginationControls.spec.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { PaginationControls } from '../../../../../src/sections/shared/pagination/PaginationControls' import { PaginationInfo } from '../../../../../src/shared/pagination/domain/models/PaginationInfo' import { FilePaginationInfo } from '../../../../../src/files/domain/models/FilePaginationInfo' @@ -119,6 +120,36 @@ describe('PaginationControls', () => { cy.wrap(onPaginationInfoChange).should('have.been.calledWith', paginationInfo.goToPage(20)) }) + it('disables first and previous buttons on the first page', () => { + cy.customMount( + {}} + /> + ) + + cy.findByRole('button', { name: 'First' }).should('not.exist') + cy.findByRole('button', { name: 'Previous' }).should('not.exist') + cy.findByRole('button', { name: 'Next' }).should('exist').should('not.have.class', 'disabled') + cy.findByRole('button', { name: 'Last' }).should('exist').should('not.have.class', 'disabled') + }) + + it('disables next and last buttons on the last page', () => { + cy.customMount( + {}} + /> + ) + + cy.findByRole('button', { name: 'First' }).should('exist').should('not.have.class', 'disabled') + cy.findByRole('button', { name: 'Previous' }) + .should('exist') + .should('not.have.class', 'disabled') + cy.findByRole('button', { name: 'Next' }).should('not.exist') + cy.findByRole('button', { name: 'Last' }).should('not.exist') + }) + it('selecting a page size calls setPageSize with the selected value', () => { const onPaginationInfoChange = cy.stub().as('onPaginationInfoChange') cy.customMount( @@ -138,6 +169,34 @@ describe('PaginationControls', () => { ) }) + it('clicking a page button after selecting a page size of 50 goes to page 5', () => { + const onPaginationInfoChange = cy.stub().as('onPaginationInfoChange') + // total=250 with pageSize=50 gives exactly 5 pages, so a "5" button exists to click. + const initialPaginationInfo = new PaginationInfo( + 1, + pageSize, + 250 + ) + cy.customMount( + + ) + + cy.findByLabelText('Items per page').select('50') + cy.wrap(onPaginationInfoChange).should( + 'have.been.calledWith', + initialPaginationInfo.withPageSize(50) + ) + + cy.findByRole('button', { name: '5' }).click() + cy.wrap(onPaginationInfoChange).should( + 'have.been.calledWith', + initialPaginationInfo.withPageSize(50).goToPage(5) + ) + }) + it('does not show the page size selector if the prop is false', () => { const onPaginationInfoChange = cy.stub().as('onPaginationInfoChange') cy.customMount( @@ -150,4 +209,41 @@ describe('PaginationControls', () => { cy.findByLabelText('Items per page').should('not.exist') }) + + it('updates pagination controls when the total number of items changes', () => { + const onPaginationInfoChange = cy.stub().as('onPaginationInfoChange') + const initialPaginationInfo = new PaginationInfo( + 1, + pageSize, + 10 + ) + function TestHost() { + const [currentPaginationInfo, setCurrentPaginationInfo] = useState(initialPaginationInfo) + return ( + <> + + + + ) + } + + cy.customMount() + + cy.findByRole('button', { name: 'Next' }).should('not.exist') + cy.findByRole('button', { name: 'Increase total' }).click() + + cy.findByRole('button', { name: '3' }).should('exist') + cy.wrap(onPaginationInfoChange).should('not.have.been.called') + + cy.findByRole('button', { name: 'Last' }).click() + cy.wrap(onPaginationInfoChange).should( + 'have.been.calledWith', + initialPaginationInfo.withTotal(30).goToPage(3) + ) + }) }) diff --git a/tests/component/shared/hooks/useGetCollectionAllowedStorageDrivers.spec.tsx b/tests/component/shared/hooks/useGetCollectionAllowedStorageDrivers.spec.tsx new file mode 100644 index 000000000..e722a2213 --- /dev/null +++ b/tests/component/shared/hooks/useGetCollectionAllowedStorageDrivers.spec.tsx @@ -0,0 +1,91 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { useGetCollectionAllowedStorageDrivers } from '@/shared/hooks/useGetCollectionAllowedStorageDrivers' + +const collectionRepository: CollectionRepository = {} as CollectionRepository + +const allowedStorageDriversMock = { + s3: 's3', + swift: 'Swift' +} + +describe('useGetCollectionAllowedStorageDrivers', () => { + it('should return allowed storage drivers correctly', async () => { + collectionRepository.getAllowedStorageDrivers = cy.stub().resolves(allowedStorageDriversMock) + + const { result } = renderHook(() => + useGetCollectionAllowedStorageDrivers({ + collectionRepository, + collectionIdOrAlias: 'root' + }) + ) + + expect(result.current.isLoading).to.deep.equal(true) + expect(result.current.allowedStorageDrivers).to.deep.equal({}) + + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.allowedStorageDrivers).to.deep.equal(allowedStorageDriversMock) + }) + }) + + it('should not fetch allowed storage drivers when disabled', () => { + const getAllowedStorageDrivers = cy.stub().as('getAllowedStorageDrivers') + collectionRepository.getAllowedStorageDrivers = getAllowedStorageDrivers + + const { result } = renderHook(() => + useGetCollectionAllowedStorageDrivers({ + collectionRepository, + collectionIdOrAlias: 'root', + enabled: false + }) + ) + + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.allowedStorageDrivers).to.deep.equal({}) + expect(result.current.error).to.deep.equal(null) + expect(getAllowedStorageDrivers).not.to.have.been.called + }) + + describe('Error handling', () => { + it('should return correct error message when there is an error type catched', async () => { + collectionRepository.getAllowedStorageDrivers = cy.stub().rejects(new Error('Error message')) + + const { result } = renderHook(() => + useGetCollectionAllowedStorageDrivers({ + collectionRepository, + collectionIdOrAlias: 'root' + }) + ) + + expect(result.current.isLoading).to.deep.equal(true) + expect(result.current.error).to.deep.equal(null) + + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.error).to.deep.equal('Error message') + }) + }) + + it('should return correct error message when there is not an error type catched', async () => { + collectionRepository.getAllowedStorageDrivers = cy.stub().rejects('Error message') + + const { result } = renderHook(() => + useGetCollectionAllowedStorageDrivers({ + collectionRepository, + collectionIdOrAlias: 'root' + }) + ) + + expect(result.current.isLoading).to.deep.equal(true) + expect(result.current.error).to.deep.equal(null) + + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.error).to.deep.equal( + 'Something went wrong getting the allowed storage drivers for this collection. Try again later.' + ) + }) + }) + }) +}) diff --git a/tests/component/shared/hooks/useGetCollectionStorageDriver.spec.tsx b/tests/component/shared/hooks/useGetCollectionStorageDriver.spec.tsx new file mode 100644 index 000000000..0031a1e51 --- /dev/null +++ b/tests/component/shared/hooks/useGetCollectionStorageDriver.spec.tsx @@ -0,0 +1,119 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { StorageDriver } from '@/collection/domain/models/StorageDriver' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { useGetCollectionStorageDriver } from '@/shared/hooks/useGetCollectionStorageDriver' + +const collectionRepository: CollectionRepository = {} as CollectionRepository + +const storageDriverMock: StorageDriver = { + name: 's3', + type: 's3', + label: 's3', + directUpload: true, + directDownload: true, + uploadOutOfBand: false +} + +describe('useGetCollectionStorageDriver', () => { + it('should return storage driver correctly', async () => { + const getStorageDriver = cy.stub().as('getStorageDriver').resolves(storageDriverMock) + collectionRepository.getStorageDriver = getStorageDriver + + const { result } = renderHook(() => + useGetCollectionStorageDriver({ + collectionRepository, + collectionIdOrAlias: 'root', + getEffective: true + }) + ) + + expect(result.current.isLoading).to.deep.equal(true) + expect(result.current.storageDriver).to.deep.equal(null) + + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.storageDriver).to.deep.equal(storageDriverMock) + expect(getStorageDriver).to.have.been.calledWith('root', true) + }) + }) + + it('should return null when storage driver is undefined', async () => { + collectionRepository.getStorageDriver = cy.stub().resolves(undefined) + + const { result } = renderHook(() => + useGetCollectionStorageDriver({ + collectionRepository, + collectionIdOrAlias: 'root', + getEffective: false + }) + ) + + expect(result.current.isLoading).to.deep.equal(true) + expect(result.current.storageDriver).to.deep.equal(null) + + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.storageDriver).to.deep.equal(null) + }) + }) + + it('should not fetch storage driver when disabled', () => { + const getStorageDriver = cy.stub().as('getStorageDriver') + collectionRepository.getStorageDriver = getStorageDriver + + const { result } = renderHook(() => + useGetCollectionStorageDriver({ + collectionRepository, + collectionIdOrAlias: 'root', + enabled: false + }) + ) + + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.storageDriver).to.deep.equal(null) + expect(result.current.error).to.deep.equal(null) + expect(getStorageDriver).not.to.have.been.called + }) + + describe('Error handling', () => { + it('should return correct error message when there is an error type catched', async () => { + collectionRepository.getStorageDriver = cy.stub().rejects(new Error('Error message')) + + const { result } = renderHook(() => + useGetCollectionStorageDriver({ + collectionRepository, + collectionIdOrAlias: 'root' + }) + ) + + expect(result.current.isLoading).to.deep.equal(true) + expect(result.current.error).to.deep.equal(null) + + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.error).to.deep.equal('Error message') + }) + }) + + it('should return correct error message when there is not an error type catched', async () => { + collectionRepository.getStorageDriver = cy.stub().rejects('Error message') + + const { result } = renderHook(() => + useGetCollectionStorageDriver({ + collectionRepository, + collectionIdOrAlias: 'root' + }) + ) + + expect(result.current.isLoading).to.deep.equal(true) + expect(result.current.error).to.deep.equal(null) + + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.error).to.deep.equal( + 'Something went wrong getting the storage driver for this collection. Try again later.' + ) + }) + }) + }) +})