diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f90edbeb..f16567229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added - Added the value entered by the user in the error messages for metadata field validation errors in EMAIL and URL type fields. For example, instead of showing “Point of Contact E-mail is not a valid email address.“, we now show “Point of Contact E-mail foo is not a valid email address.” +- Dataset Templates UI integration, including create/edit flows, previews, and skeleton states. - Contact Owner button in File Page. - Share button in File Page. - Link Collection and Link Dataset features. diff --git a/cypress.config.ts b/cypress.config.ts index f5efec081..8944254cf 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ ], defaultLanguage: 'en', codeCoverage: { - exclude: ['tests/**/*.*', '**/ErrorPage.tsx'] + exclude: ['tests/**/*.*', '**/ErrorPage.tsx', '**/EditGuestBook.tsx'] } } }) diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index c6f1a3a88..db2c27b47 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -52,6 +52,7 @@ "editCollection": { "edit": "Edit", "generalInfo": "General Information", + "datasetTemplates": "Dataset Templates", "deleteCollection": "Delete Collection" }, "featuredItems": { diff --git a/public/locales/en/datasetTemplates.json b/public/locales/en/datasetTemplates.json new file mode 100644 index 000000000..7f70cc561 --- /dev/null +++ b/public/locales/en/datasetTemplates.json @@ -0,0 +1,96 @@ +{ + "pageTitle": "Dataset Templates", + "infoAlert": { + "title": "Manage Dataset Templates", + "text": "Create a template prefilled with metadata fields standard values, such as Author Affiliation, or add instructions in the metadata fields to give depositors more information on what metadata is expected." + }, + "actions": { + "create": "Create Dataset Template", + "makeDefault": "Make Default", + "default": "Default", + "view": "View", + "copy": "Copy", + "edit": "Edit Template", + "delete": "Delete", + "deleteDisabledTip": "This template is already used by datasets and cannot be deleted." + }, + "filters": { + "includeTemplatesFromRoot": "Include Templates from {{root}}" + }, + "table": { + "name": "Template Name", + "created": "Date Created", + "usage": "Usage", + "action": "Action", + "templateCreatedAt": "Template created at {{alias}}" + }, + "emptyState": { + "whyTitle": "Why Use Templates?", + "whyBullets": [ + "Templates are useful when you have several datasets that have the same information in multiple metadata fields that you would prefer not to have to keep manually typing in.", + "Templates can be used to input instructions for those uploading datasets into your dataverse if you have a specific way you want a metadata field to be filled out." + ], + "howTitle": "How To Use Templates?", + "howBullets": [ + "Templates are created at the dataverse level, can be deleted (so it does not show for future datasets), set to default (not required), and can be copied so you do not have to start over when creating a new template with similar metadata from another template. When a template is deleted, it does not impact the datasets that have used the template already." + ], + "howNote": "Please note that the ability to choose which metadata fields are hidden, required, or optional is done on the General Information page for this dataverse.", + "footer": "To get started, click on the Create Dataset Template button above. To learn more about templates, visit the Dataset Templates section of the User Guide." + }, + "deleteModal": { + "title": "Delete Template", + "message": "Are you sure you want to delete this template? A new dataset will not be able to use this template." + }, + "alerts": { + "deleteSuccess": "Template deleted.", + "deleteError": "Something went wrong deleting the template. Try again later.", + "copySuccess": "Template copied.", + "copyError": "Something went wrong copying the template. Try again later." + }, + "copyNamePrefix": "copy {{name}}", + "preview": { + "title": "Dataset Template Preview", + "templateLabel": "Dataset Template", + "sections": { + "metadata": "Citation Metadata", + "terms": "Dataset Terms", + "access": "Restricted Files + Terms of Access" + }, + "noMetadata": "No citation metadata is available for this template.", + "error": "Something went wrong loading the template preview. Please try again." + }, + "createTemplate": { + "pageTitle": "Create Dataset Template", + "breadcrumb": "Create", + "templateName": "Template Name", + "requiredInfo": "Asterisks indicate metadata fields that users will be required to fill out while adding a dataset to this dataverse.", + "citationMissing": "Citation metadata is not available for this template.", + "saveAddTerms": "Save + Add Terms", + "cancel": "Cancel", + "alerts": { + "success": "Success! Template has been created." + }, + "errors": { + "nameRequired": "Please add in a name for the dataset template.", + "saveFailed": "Something went wrong creating the template. Try again later." + }, + "customInstructions": { + "label": "Custom Instructions:", + "none": "(None - click to add)", + "addAria": "Add custom instructions", + "editAria": "Edit custom instructions", + "inputAriaLabel": "Custom instructions", + "save": "Save", + "cancel": "Cancel" + }, + "terms": { + "datasetTermsTitle": "Dataset Terms", + "licenseLabel": "License/Data Use Agreement", + "restrictedTitle": "Restricted Files + Terms of Access", + "requestAccessLabel": "Request Access", + "requestAccessHelp": "Enable access request", + "termsOfAccessLabel": "Terms of Access for Restricted Files", + "dataAccessPlaceLabel": "Data Access Place" + } + } +} diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index d4dbc4e26..ab340eced 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -7,6 +7,7 @@ "files": "Files", "page": "Page", "asterisksIndicateRequiredFields": "Asterisks indicate required fields", + "asterisksRequiredDatasetFields": "Asterisks indicate metadata fields that users will be required to fill out while adding a dataset to this dataverse.", "remove": "Remove", "add": "Add", "yes": "Yes", diff --git a/public/locales/es/collection.json b/public/locales/es/collection.json index a7b718fa1..e0a963c09 100644 --- a/public/locales/es/collection.json +++ b/public/locales/es/collection.json @@ -52,6 +52,7 @@ "editCollection": { "edit": "Editar", "generalInfo": "Información general", + "datasetTemplates": "Plantillas de conjuntos de datos", "deleteCollection": "Eliminar colección" }, "featuredItems": { diff --git a/public/locales/es/datasetTemplates.json b/public/locales/es/datasetTemplates.json new file mode 100644 index 000000000..13bb0c65d --- /dev/null +++ b/public/locales/es/datasetTemplates.json @@ -0,0 +1,95 @@ +{ + "pageTitle": "Plantillas de conjuntos de datos", + "infoAlert": { + "title": "Gestionar plantillas de conjuntos de datos", + "text": "Cree una plantilla precargada con valores estándar de campos de metadatos, como Afiliación del autor, o agregue instrucciones en los campos de metadatos para dar a los depositantes más información sobre qué metadatos se esperan." + }, + "actions": { + "create": "Crear plantilla de conjunto de datos", + "makeDefault": "Hacer predeterminada", + "default": "Predeterminada", + "view": "Ver", + "copy": "Copiar", + "edit": "Editar plantilla", + "delete": "Eliminar" + }, + "filters": { + "includeTemplatesFromRoot": "Incluir plantillas de {{root}}" + }, + "table": { + "name": "Nombre de la plantilla", + "created": "Fecha de creación", + "usage": "Uso", + "action": "Acción", + "templateCreatedAt": "Plantilla creada en {{alias}}" + }, + "emptyState": { + "whyTitle": "¿Por qué usar plantillas?", + "whyBullets": [ + "Las plantillas son útiles cuando tienes varios conjuntos de datos que tienen la misma información en múltiples campos de metadatos y preferirías no tener que seguir escribiéndola manualmente.", + "Las plantillas se pueden usar para incluir instrucciones para quienes cargan conjuntos de datos en tu dataverse si tienes una forma específica en la que quieres que se complete un campo de metadatos." + ], + "howTitle": "Cómo usar plantillas", + "howBullets": [ + "Las plantillas se crean a nivel de dataverse, se pueden eliminar (para que no aparezcan en futuros conjuntos de datos), establecer como predeterminadas (no obligatorias) y copiar para que no tengas que empezar de cero al crear una nueva plantilla con metadatos similares a otra. Cuando se elimina una plantilla, no afecta a los conjuntos de datos que ya la hayan usado." + ], + "howNote": "Ten en cuenta que la capacidad de elegir qué campos de metadatos están ocultos, son obligatorios u opcionales se configura en la página de Información General de este dataverse.", + "footer": "Para comenzar, haz clic en el botón Crear plantilla de conjunto de datos de arriba. Para obtener más información sobre las plantillas, visita la sección Plantillas de conjuntos de datos de la Guía del usuario." + }, + "deleteModal": { + "title": "Eliminar plantilla", + "message": "¿Está seguro de que desea eliminar esta plantilla? Un nuevo conjunto de datos no podrá usar esta plantilla." + }, + "alerts": { + "deleteSuccess": "Plantilla eliminada.", + "deleteError": "Algo salió mal al eliminar la plantilla. Inténtelo de nuevo más tarde.", + "copySuccess": "Plantilla copiada.", + "copyError": "Algo salió mal al copiar la plantilla. Inténtelo de nuevo más tarde." + }, + "copyNamePrefix": "copia {{name}}", + "preview": { + "title": "Vista previa de plantilla de conjunto de datos", + "templateLabel": "Plantilla de conjunto de datos", + "sections": { + "metadata": "Metadatos de citación", + "terms": "Términos del conjunto de datos", + "access": "Archivos restringidos + términos de acceso" + }, + "noMetadata": "No hay metadatos de citación disponibles para esta plantilla.", + "error": "No se pudo cargar la vista previa de la plantilla. Inténtelo de nuevo más tarde." + }, + "createTemplate": { + "pageTitle": "Crear plantilla de conjunto de datos", + "breadcrumb": "Crear", + "templateName": "Nombre de la plantilla", + "requiredInfo": "Los asteriscos indican los campos de metadatos que los usuarios deberán completar al agregar un conjunto de datos a este dataverse.", + "citationMissing": "Los metadatos de citación no están disponibles para esta plantilla.", + "saveAddTerms": "Guardar + agregar términos", + "cancel": "Cancelar", + "alerts": { + "success": "¡Éxito! La plantilla ha sido creada." + }, + "errors": { + "nameRequired": "Por favor, añade un nombre para la plantilla del conjunto de datos.", + "saveFailed": "Algo salió mal al crear la plantilla. Inténtelo de nuevo más tarde." + }, + "customInstructions": { + "label": "Instrucciones personalizadas:", + "none": "(Ninguna: haz clic para añadir)", + "addAria": "Agregar instrucciones personalizadas", + "editAria": "Editar instrucciones personalizadas", + "inputAriaLabel": "Instrucciones personalizadas", + "save": "Guardar", + "cancel": "Cancelar" + }, + "terms": { + "datasetTermsTitle": "Términos del conjunto de datos", + "licenseLabel": "Licencia/Acuerdo de uso de datos", + "restrictedTitle": "Archivos restringidos + términos de acceso", + "requestAccessLabel": "Solicitar acceso", + "requestAccessHelp": "Habilitar solicitud de acceso", + "termsOfAccessLabel": "Términos de acceso para archivos restringidos", + "dataAccessPlaceLabel": "Lugar de acceso a los datos" + } + } +} diff --git a/public/locales/es/shared.json b/public/locales/es/shared.json index a3010a550..f924716ea 100644 --- a/public/locales/es/shared.json +++ b/public/locales/es/shared.json @@ -7,6 +7,7 @@ "files": "Ficheros", "page": "Página", "asterisksIndicateRequiredFields": "Los asteriscos indican los campos obligatorios", + "asterisksRequiredDatasetFields": "Los asteriscos indican los campos de metadatos que los usuarios deberán completar al agregar un conjunto de datos a este dataverse.", "remove": "Eliminar", "add": "Agregar", "yes": "Sí", diff --git a/src/dataset/domain/useCases/DTOs/DatasetDTO.ts b/src/dataset/domain/useCases/DTOs/DatasetDTO.ts index 32bd84bd6..ee65edf8e 100644 --- a/src/dataset/domain/useCases/DTOs/DatasetDTO.ts +++ b/src/dataset/domain/useCases/DTOs/DatasetDTO.ts @@ -10,9 +10,9 @@ export interface DatasetMetadataBlockValuesDTO { fields: DatasetMetadataFieldsDTO } -type DatasetMetadataFieldsDTO = Record +export type DatasetMetadataFieldsDTO = Record -type DatasetMetadataFieldValueDTO = +export type DatasetMetadataFieldValueDTO = | string | string[] | DatasetMetadataChildFieldValueDTO diff --git a/src/dataset/domain/useCases/updateDatasetLicense.ts b/src/dataset/domain/useCases/updateDatasetLicense.ts index c6db91658..a4d42d051 100644 --- a/src/dataset/domain/useCases/updateDatasetLicense.ts +++ b/src/dataset/domain/useCases/updateDatasetLicense.ts @@ -6,9 +6,5 @@ export function updateDatasetLicense( datasetId: string | number, licenseUpdateRequest: DatasetLicenseUpdateRequest ): Promise { - return datasetRepository - .updateDatasetLicense(datasetId, licenseUpdateRequest) - .catch((error: Error) => { - throw new Error(error.message) - }) + return datasetRepository.updateDatasetLicense(datasetId, licenseUpdateRequest) } diff --git a/src/dataset/domain/useCases/updateTermsOfAccess.ts b/src/dataset/domain/useCases/updateTermsOfAccess.ts index ea44aaf55..fea3398ef 100644 --- a/src/dataset/domain/useCases/updateTermsOfAccess.ts +++ b/src/dataset/domain/useCases/updateTermsOfAccess.ts @@ -6,7 +6,5 @@ export function updateTermsOfAccess( datasetId: string | number, termsOfAccess: TermsOfAccess ): Promise { - return datasetRepository.updateTermsOfAccess(datasetId, termsOfAccess).catch((error: Error) => { - throw new Error(error.message) - }) + return datasetRepository.updateTermsOfAccess(datasetId, termsOfAccess) } diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 089a17d85..c664b40fa 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -93,6 +93,28 @@ const EditFeaturedItems = lazy(() => ) ) +const DatasetTemplatesPage = lazy(() => + import('../sections/templates/DatasetTemplatesFactory').then(({ DatasetTemplatesFactory }) => ({ + default: () => DatasetTemplatesFactory.create() + })) +) + +const CreateTemplatePage = lazy(() => + import('../sections/templates/create-template/CreateTemplateFactory').then( + ({ CreateTemplateFactory }) => ({ + default: () => CreateTemplateFactory.create() + }) + ) +) + +const EditDatasetTemplateTermsPage = lazy(() => + import('../sections/templates/edit-template-terms/EditTemplateTermsFactory').then( + ({ EditTemplateTermsFactory }) => ({ + default: () => EditTemplateTermsFactory.create() + }) + ) +) + const ReplaceFile = lazy(() => import('../sections/replace-file/ReplaceFileFactory').then(({ ReplaceFileFactory }) => ({ default: () => ReplaceFileFactory.create() @@ -292,6 +314,33 @@ export const routes: RouteObject[] = [ ), errorElement: }, + { + path: Route.COLLECTION_TEMPLATES, + element: ( + }> + + + ), + errorElement: + }, + { + path: Route.TEMPLATES_CREATE, + element: ( + }> + + + ), + errorElement: + }, + { + path: Route.TEMPLATES_EDIT_TERMS, + element: ( + }> + + + ), + errorElement: + }, { path: Route.EDIT_FILE_METADATA, element: ( diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 133e043af..d9853c57a 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -20,6 +20,10 @@ export enum Route { ACCOUNT = '/account', EDIT_COLLECTION = '/collections/:collectionId/edit', EDIT_FEATURED_ITEMS = '/collections/:collectionId/edit-featured-items', + COLLECTION_TEMPLATES = '/:collectionId/templates', + TEMPLATES_CREATE = '/:collectionId/templates/create', + TEMPLATES_EDIT_METADATA = '/:collectionId/templates/:templateId/edit/metadata', + TEMPLATES_EDIT_TERMS = '/:collectionId/templates/:templateId/edit/terms', FEATURED_ITEM = '/featured-item/:parentCollectionId/:featuredItemId', NOT_FOUND_PAGE = '/404', AUTH_CALLBACK = '/auth-callback', @@ -34,6 +38,12 @@ export const RouteWithParams = { CREATE_DATASET: (collectionId: string) => `/datasets/${collectionId}/create`, EDIT_COLLECTION: (collectionId: string) => `/collections/${collectionId}/edit`, EDIT_FEATURED_ITEMS: (collectionId: string) => `/collections/${collectionId}/edit-featured-items`, + COLLECTION_TEMPLATES: (collectionId: string) => `/${collectionId}/templates`, + TEMPLATES_CREATE: (collectionId: string) => `/${collectionId}/templates/create`, + TEMPLATES_EDIT_METADATA: (collectionId: string, templateId: number | string) => + `/${collectionId}/templates/${templateId}/edit/metadata`, + TEMPLATES_EDIT_TERMS: (collectionId: string, templateId: number | string) => + `/${collectionId}/templates/${templateId}/edit/terms`, EDIT_FILE_METADATA: ( datasetPersistentId: string, datasetVersion: string, diff --git a/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx b/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx index eace09e12..d185bd29d 100644 --- a/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx +++ b/src/sections/collection/edit-collection-dropdown/EditCollectionDropdown.tsx @@ -60,6 +60,9 @@ export const EditCollectionDropdown = ({ {t('featuredItems.title')} + + {t('editCollection.datasetTemplates')} + {canCollectionBeDeleted && ( <> diff --git a/src/sections/create-dataset/CreateDataset.tsx b/src/sections/create-dataset/CreateDataset.tsx index 0460ee96e..227cc3185 100644 --- a/src/sections/create-dataset/CreateDataset.tsx +++ b/src/sections/create-dataset/CreateDataset.tsx @@ -15,7 +15,7 @@ import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { useCollection } from '../collection/useCollection' import { NotFoundPage } from '../not-found-page/NotFoundPage' import { CreateDatasetSkeleton } from './CreateDatasetSkeleton' -import { useGetTemplatesByCollectionId } from '@/dataset/domain/hooks/useGetTemplatesByCollectionId' +import { useGetTemplatesByCollectionId } from '@/templates/domain/hooks/useGetTemplatesByCollectionId' import { type Template } from '@/templates/domain/models/Template' import { DatasetTemplateSelect } from './dataset-template-select/DatasetTemplateSelect' import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository' diff --git a/src/sections/dataset/dataset-metadata/dataset-metadata-fields/DatasetMetadataField.tsx b/src/sections/dataset/dataset-metadata/dataset-metadata-fields/DatasetMetadataField.tsx index ccb564a04..2f85e02e0 100644 --- a/src/sections/dataset/dataset-metadata/dataset-metadata-fields/DatasetMetadataField.tsx +++ b/src/sections/dataset/dataset-metadata/dataset-metadata-fields/DatasetMetadataField.tsx @@ -4,20 +4,24 @@ import { DatasetMetadataFieldValue as DatasetMetadataFieldValueModel } from '../ import { DatasetMetadataFieldValue } from './DatasetMetadataFieldValue' import { DatasetMetadataFieldTitle } from './DatasetMetadataFieldTitle' import { MetadataBlockInfoDisplayFormat } from '../../../../metadata-block-info/domain/models/MetadataBlockInfo' +import { DatasetTemplateInstruction } from '@/templates/domain/models/Template' import styles from './DatasetMetadataField.module.scss' interface DatasetMetadataFieldProps { metadataFieldName: string metadataFieldValue: DatasetMetadataFieldValueModel metadataBlockDisplayFormatInfo: MetadataBlockInfoDisplayFormat + datasetTemplateInstructions?: DatasetTemplateInstruction[] } export function DatasetMetadataField({ metadataFieldName, metadataFieldValue, - metadataBlockDisplayFormatInfo + metadataBlockDisplayFormatInfo, + datasetTemplateInstructions }: DatasetMetadataFieldProps) { const { t } = useTranslation('dataset') + const { t: tTemplates } = useTranslation('datasetTemplates') // To show custom titles and descriptions for specific fields const getFieldInfo = (fieldName: string) => { @@ -58,12 +62,21 @@ export function DatasetMetadataField({ const { title, description } = getFieldInfo(metadataFieldName) const fieldTip = getFieldTip(metadataFieldName) + const customInstructionText = datasetTemplateInstructions + ?.find((instruction) => instruction.instructionField === metadataFieldName) + ?.instructionText?.trim() + return ( + {customInstructionText ? ( + + {tTemplates('createTemplate.customInstructions.label')} {customInstructionText} + + ) : null} {fieldTip && {fieldTip}} @@ -21,6 +24,7 @@ export function DatasetMetadataFields({ metadataFieldName={metadataFieldName} metadataFieldValue={metadataFieldValue} metadataBlockDisplayFormatInfo={metadataBlockDisplayFormatInfo} + datasetTemplateInstructions={datasetTemplateInstructions} /> ))} diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index 488e977d5..5be6382bc 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -7,7 +7,9 @@ import { import { DatasetDTO, DatasetMetadataBlockValuesDTO, - DatasetMetadataChildFieldValueDTO + DatasetMetadataFieldValueDTO, + DatasetMetadataChildFieldValueDTO, + DatasetMetadataFieldsDTO } from '../../../../dataset/domain/useCases/DTOs/DatasetDTO' import { DatasetMetadataBlock, @@ -16,6 +18,12 @@ import { DatasetMetadataSubField, defaultLicense } from '../../../../dataset/domain/models/Dataset' +import { + TemplateFieldCompoundChildValue, + TemplateFieldCompoundValue, + TemplateFieldInfo, + TemplateFieldValue +} from '../../../../templates/domain/models/TemplateInfo' export type DatasetMetadataFormValues = Record @@ -409,6 +417,99 @@ export class MetadataFieldsHelper { return { licence: defaultLicense, metadataBlocks } } + public static buildTemplateFieldsFromMetadataValues( + fieldValues: DatasetMetadataFieldsDTO, + metadataFields: Record + ): TemplateFieldInfo[] { + const templateFields: TemplateFieldInfo[] = [] + + Object.entries(fieldValues).forEach(([fieldName, fieldValue]) => { + const fieldInfo = metadataFields[fieldName] + if (!fieldInfo) return + + if (fieldInfo.typeClass === 'primitive' || fieldInfo.typeClass === 'controlledVocabulary') { + if (fieldValue === '' || fieldValue === undefined || fieldValue === null) return + + if (Array.isArray(fieldValue)) { + if (!fieldValue.every((item) => typeof item === 'string')) return + + templateFields.push({ + typeName: fieldInfo.name, + multiple: fieldInfo.multiple, + typeClass: fieldInfo.typeClass, + value: fieldValue as TemplateFieldValue + }) + return + } + + if (typeof fieldValue === 'string') { + templateFields.push({ + typeName: fieldInfo.name, + multiple: fieldInfo.multiple, + typeClass: fieldInfo.typeClass, + value: fieldValue + }) + } + + return + } + + if (fieldInfo.typeClass === 'compound') { + const compoundValues = this.buildTemplateCompoundValues(fieldInfo, fieldValue) + if (compoundValues.length === 0) return + + const valuePayload: TemplateFieldValue = fieldInfo.multiple + ? compoundValues + : compoundValues[0] + + templateFields.push({ + typeName: fieldInfo.name, + multiple: fieldInfo.multiple, + typeClass: fieldInfo.typeClass, + value: valuePayload + }) + } + }) + + return templateFields + } + + private static buildTemplateCompoundValues( + fieldInfo: MetadataField, + fieldValue: DatasetMetadataFieldValueDTO + ): TemplateFieldCompoundValue[] { + if (fieldInfo.typeClass !== 'compound') { + return [] + } + const valueArray = Array.isArray(fieldValue) ? fieldValue : [fieldValue] + const compoundValues: TemplateFieldCompoundValue[] = [] + + valueArray.forEach((compoundValue) => { + if (!compoundValue || typeof compoundValue !== 'object' || Array.isArray(compoundValue)) { + return + } + const entry: Record = {} + + Object.entries(compoundValue).forEach(([childName, childValue]) => { + const childInfo = fieldInfo.childMetadataFields?.[childName] + if (!childInfo) return + if (childValue === '' || childValue === undefined || childValue === null) return + + entry[childInfo.name] = { + value: childValue as string | string[], + typeName: childInfo.name, + multiple: childInfo.multiple, + typeClass: childInfo.typeClass + } + }) + if (Object.keys(entry).length > 0) { + compoundValues.push(entry) + } + }) + + return compoundValues + } + public static addFieldValuesToMetadataBlocksInfo( normalizedMetadataBlocksInfo: MetadataBlockInfo[], normalizedDatasetMetadaBlocksCurrentValues: DatasetMetadataBlock[] diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/CustomInstructionsEditor.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/CustomInstructionsEditor.tsx new file mode 100644 index 000000000..f63fd76cb --- /dev/null +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/CustomInstructionsEditor.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Form } from '@iqss/dataverse-design-system' +import { Check, X } from 'react-bootstrap-icons' +import { TemplateInstructionInfo } from '@/templates/domain/models/TemplateInfo' +import styles from './index.module.scss' + +interface CustomInstructionsEditorProps { + value?: string + onSave: (instruction: TemplateInstructionInfo) => void + fieldKey: string +} + +export const CustomInstructionsEditor = ({ + value, + onSave, + fieldKey +}: CustomInstructionsEditorProps) => { + const { t } = useTranslation('datasetTemplates', { + keyPrefix: 'createTemplate.customInstructions' + }) + const [isEditing, setIsEditing] = useState(false) + const [draft, setDraft] = useState(value ?? '') + + useEffect(() => { + if (!isEditing) { + setDraft(value ?? '') + } + }, [isEditing, value]) + + const handleStartEdit = () => { + setIsEditing(true) + setDraft(value ?? '') + } + + const handleCancel = () => { + setIsEditing(false) + setDraft(value ?? '') + } + + const handleSave = () => { + const trimmed = draft.trim() + onSave({ instructionField: fieldKey, instructionText: trimmed }) + setIsEditing(false) + } + + return ( +
+ {t('label')} +
+ {!isEditing && ( + + )} + {isEditing && ( +
+ setDraft(event.target.value)} + className={styles['custom-instructions-input']} + aria-label={t('inputAriaLabel')} + data-testid={`custom-instructions-input-${fieldKey}`} + /> +
+ + +
+
+ )} +
+
+ ) +} diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposeFieldMultiple.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposeFieldMultiple.tsx index f2c6f0ad5..43562cbc6 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposeFieldMultiple.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposeFieldMultiple.tsx @@ -5,6 +5,8 @@ import { type MetadataField } from '../../../../../../../../metadata-block-info/ import { MetadataFormField, type CommonFieldProps } from '..' import { Col, Form, Row } from '@iqss/dataverse-design-system' import { DynamicFieldsButtons } from '../../../../../DynamicFieldsButtons/DynamicFieldsButtons' +import { CustomInstructionsEditor } from '../CustomInstructionsEditor' +import { TemplateInstructionInfo } from '@/templates/domain/models/TemplateInfo' import cn from 'classnames' import styles from '../index.module.scss' @@ -14,6 +16,8 @@ interface ComposedFieldMultipleProps extends CommonFieldProps { compoundParentName?: string fieldsArrayIndex?: number notRequiredWithChildFieldsRequired: boolean + templateInstructionValues?: Record + onTemplateInstructionChange?: (instruction: TemplateInstructionInfo) => void } export const ComposedFieldMultiple = ({ @@ -24,7 +28,11 @@ export const ComposedFieldMultiple = ({ childMetadataFields, rulesToApply, notRequiredWithChildFieldsRequired, - fieldInstructions + fieldInstructions, + instructionEditor, + templateInstructionValues, + onTemplateInstructionChange, + requiredIndicator }: ComposedFieldMultipleProps) => { const { control } = useFormContext() const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) @@ -78,9 +86,17 @@ export const ComposedFieldMultiple = ({ - {fieldInstructions && {fieldInstructions}} + {instructionEditor ? ( + + ) : ( + fieldInstructions && {fieldInstructions} + )} {notRequiredWithChildFieldsRequired && ( {t('mayBecomeRequired')} @@ -107,6 +123,9 @@ export const ComposedFieldMultiple = ({ compoundParentIsRequired={Boolean(rulesToApply?.required)} isFieldThatMayBecomeRequired={isFieldThatMayBecomeRequired} childFieldNamesThatTriggerRequired={childFieldNamesThatTriggerRequired} + templateInstructionValues={templateInstructionValues} + onTemplateInstructionChange={onTemplateInstructionChange} + suppressInstructionEditor={true} /> ) } diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposedField.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposedField.tsx index 5c4d90de1..876e64589 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposedField.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/ComposedField.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { Col, Form, Row } from '@iqss/dataverse-design-system' import { type MetadataField } from '../../../../../../../../metadata-block-info/domain/models/MetadataBlockInfo' import { MetadataFormField, type CommonFieldProps } from '..' +import { CustomInstructionsEditor } from '../CustomInstructionsEditor' +import { TemplateInstructionInfo } from '@/templates/domain/models/TemplateInfo' import styles from '../index.module.scss' interface ComposedFieldProps extends CommonFieldProps { @@ -11,6 +13,8 @@ interface ComposedFieldProps extends CommonFieldProps { compoundParentName?: string fieldsArrayIndex?: number notRequiredWithChildFieldsRequired: boolean + templateInstructionValues?: Record + onTemplateInstructionChange?: (instruction: TemplateInstructionInfo) => void } export const ComposedField = ({ @@ -21,7 +25,11 @@ export const ComposedField = ({ childMetadataFields, rulesToApply, notRequiredWithChildFieldsRequired, - fieldInstructions + fieldInstructions, + instructionEditor, + templateInstructionValues, + onTemplateInstructionChange, + requiredIndicator }: ComposedFieldProps) => { const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) @@ -49,7 +57,7 @@ export const ComposedField = ({ {notRequiredWithChildFieldsRequired && ( @@ -57,7 +65,15 @@ export const ComposedField = ({ )} - {fieldInstructions && {fieldInstructions}} + {instructionEditor ? ( + + ) : ( + fieldInstructions && {fieldInstructions} + )} {Object.entries(childMetadataFields).map( ([childMetadataFieldKey, childMetadataFieldInfo]) => { @@ -75,6 +91,9 @@ export const ComposedField = ({ compoundParentIsRequired={Boolean(rulesToApply?.required)} isFieldThatMayBecomeRequired={isFieldThatMayBecomeRequired} childFieldNamesThatTriggerRequired={childFieldNamesThatTriggerRequired} + templateInstructionValues={templateInstructionValues} + onTemplateInstructionChange={onTemplateInstructionChange} + suppressInstructionEditor={true} /> ) } diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Primitive.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Primitive.tsx index 3c7c64f1a..3eba7309b 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Primitive.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Primitive.tsx @@ -6,6 +6,7 @@ import { Col, Form, Row } from '@iqss/dataverse-design-system' import { MetadataFieldsHelper } from '../../../../MetadataFieldsHelper' import { TypeMetadataFieldOptions } from '../../../../../../../../metadata-block-info/domain/models/MetadataBlockInfo' import { type CommonFieldProps } from '..' +import { CustomInstructionsEditor } from '../CustomInstructionsEditor' import styles from '../index.module.scss' interface PrimitiveProps extends CommonFieldProps { @@ -30,7 +31,10 @@ export const Primitive = ({ fieldsArrayIndex, isFieldThatMayBecomeRequired, childFieldNamesThatTriggerRequired, - fieldInstructions + fieldInstructions, + instructionEditor, + requiredIndicator, + disableRequiredValidation }: PrimitiveProps) => { const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) const { control } = useFormContext() @@ -62,21 +66,37 @@ export const Primitive = ({ const updatedRulesToApply = useMemo(() => { if (isFieldThatMayBecomeRequired && fieldShouldBecomeRequired) { + if (disableRequiredValidation) { + return rulesToApply + } return { ...rulesToApply, required: t('field.required', { displayName, interpolation: { escapeValue: false } }) } } return rulesToApply - }, [rulesToApply, fieldShouldBecomeRequired, displayName, isFieldThatMayBecomeRequired, t]) + }, [ + rulesToApply, + fieldShouldBecomeRequired, + displayName, + isFieldThatMayBecomeRequired, + t, + disableRequiredValidation + ]) + + const dynamicRequired = + !disableRequiredValidation && isFieldThatMayBecomeRequired && fieldShouldBecomeRequired + const labelRequired = disableRequiredValidation + ? requiredIndicator + : Boolean(rulesToApply?.required) || requiredIndicator || dynamicRequired const isTextArea = type === TypeMetadataFieldOptions.Textbox return ( @@ -89,7 +109,15 @@ export const Primitive = ({ rules={updatedRulesToApply} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) => ( - {fieldInstructions && {fieldInstructions}} + {instructionEditor ? ( + + ) : ( + fieldInstructions && {fieldInstructions} + )} {isTextArea ? ( @@ -99,7 +127,7 @@ export const Primitive = ({ isInvalid={invalid} placeholder={watermark} data-fieldtype={type} - aria-required={Boolean(updatedRulesToApply?.required)} + aria-required={labelRequired ? 'true' : 'false'} ref={ref} /> ) : ( @@ -110,7 +138,7 @@ export const Primitive = ({ isInvalid={invalid} placeholder={watermark} data-fieldtype={type} - aria-required={Boolean(updatedRulesToApply?.required)} + aria-required={labelRequired ? 'true' : 'false'} ref={ref} /> )} diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/PrimitiveMultiple.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/PrimitiveMultiple.tsx index 35c5bdf7b..36b610d0a 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/PrimitiveMultiple.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/PrimitiveMultiple.tsx @@ -5,6 +5,7 @@ import { TypeMetadataFieldOptions } from '../../../../../../../../metadata-block import { DynamicFieldsButtons } from '../../../../../DynamicFieldsButtons/DynamicFieldsButtons' import { MetadataFieldsHelper } from '../../../../MetadataFieldsHelper' import { type CommonFieldProps } from '..' +import { CustomInstructionsEditor } from '../CustomInstructionsEditor' import cn from 'classnames' import styles from '../index.module.scss' @@ -22,7 +23,9 @@ export const PrimitiveMultiple = ({ rulesToApply, metadataBlockName, compoundParentName, - fieldInstructions + fieldInstructions, + instructionEditor, + requiredIndicator }: PrimitiveMultipleProps) => { const { control } = useFormContext() @@ -69,7 +72,7 @@ export const PrimitiveMultiple = ({ - {fieldInstructions && {fieldInstructions}} + {instructionEditor ? ( + + ) : ( + fieldInstructions && {fieldInstructions} + )} {(fieldsArray as { id: string; value: string }[]).map((field, index) => ( @@ -106,7 +117,7 @@ export const PrimitiveMultiple = ({ isInvalid={invalid} placeholder={watermark} data-fieldtype={type} - aria-required={Boolean(rulesToApply?.required)} + aria-required={requiredIndicator} ref={ref} id={builtFieldNameWithIndex(index)} /> diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx index c57d3eb58..b393c2e0c 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx @@ -5,6 +5,7 @@ import useWatchFieldsThatTriggerRequired from '../useWatchFieldsThatTriggerRequi import { Col, Form, Row } from '@iqss/dataverse-design-system' import { MetadataFieldsHelper } from '../../../../MetadataFieldsHelper' import { type CommonFieldProps } from '..' +import { CustomInstructionsEditor } from '../CustomInstructionsEditor' import styles from '../index.module.scss' interface VocabularyProps extends CommonFieldProps { @@ -29,7 +30,10 @@ export const Vocabulary = ({ fieldsArrayIndex, isFieldThatMayBecomeRequired, childFieldNamesThatTriggerRequired, - fieldInstructions + fieldInstructions, + instructionEditor, + requiredIndicator, + disableRequiredValidation }: VocabularyProps) => { const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) @@ -62,14 +66,28 @@ export const Vocabulary = ({ const updatedRulesToApply = useMemo(() => { if (isFieldThatMayBecomeRequired && fieldShouldBecomeRequired) { + if (disableRequiredValidation) { + return rulesToApply + } return { ...rulesToApply, required: t('field.required', { displayName, interpolation: { escapeValue: false } }) } } return rulesToApply - }, [rulesToApply, fieldShouldBecomeRequired, displayName, isFieldThatMayBecomeRequired, t]) - + }, [ + rulesToApply, + fieldShouldBecomeRequired, + displayName, + isFieldThatMayBecomeRequired, + t, + disableRequiredValidation + ]) + const dynamicRequired = + !disableRequiredValidation && isFieldThatMayBecomeRequired && fieldShouldBecomeRequired + const labelRequired = disableRequiredValidation + ? requiredIndicator + : Boolean(rulesToApply?.required) || requiredIndicator || dynamicRequired const showSelectWithSearch = options.length > 10 return ( @@ -83,7 +101,7 @@ export const Vocabulary = ({ as={withinMultipleFieldsGroup ? Col : Row}> - {fieldInstructions && {fieldInstructions}} + {instructionEditor ? ( + + ) : ( + fieldInstructions && {fieldInstructions} + )} {showSelectWithSearch ? ( @@ -108,7 +134,7 @@ export const Vocabulary = ({ onChange={onChange} value={value as string} isInvalid={invalid} - aria-required={Boolean(updatedRulesToApply?.required)} + aria-required={labelRequired ? 'true' : 'false'} ref={ref}> {options.map((option) => ( diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx index 6da2c13b4..030b350bd 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx @@ -3,6 +3,7 @@ import { Controller, useFormContext } from 'react-hook-form' import { Form, Row, Col } from '@iqss/dataverse-design-system' import { MetadataFieldsHelper } from '../../../../MetadataFieldsHelper' import { type CommonFieldProps } from '..' +import { CustomInstructionsEditor } from '../CustomInstructionsEditor' import styles from '../index.module.scss' interface VocabularyProps extends CommonFieldProps { @@ -20,7 +21,9 @@ export const VocabularyMultiple = ({ metadataBlockName, compoundParentName, fieldsArrayIndex, - fieldInstructions + fieldInstructions, + instructionEditor, + requiredIndicator }: VocabularyProps) => { const { control } = useFormContext() @@ -45,14 +48,22 @@ export const VocabularyMultiple = ({ {title} - {fieldInstructions && {fieldInstructions}} + {instructionEditor ? ( + + ) : ( + fieldInstructions && {fieldInstructions} + )} void + fieldKey: string + } } type DynamicMetadataFormFieldProps = @@ -37,6 +45,10 @@ type DynamicMetadataFormFieldProps = isFieldThatMayBecomeRequired?: never childFieldNamesThatTriggerRequired?: never datasetTemplateInstructions?: DatasetTemplateInstruction[] + templateInstructionValues?: Record + onTemplateInstructionChange?: (instruction: TemplateInstructionInfo) => void + suppressInstructionEditor?: boolean + disableRequiredValidation?: boolean } | { metadataFieldInfo: MetadataField @@ -48,6 +60,10 @@ type DynamicMetadataFormFieldProps = isFieldThatMayBecomeRequired: boolean childFieldNamesThatTriggerRequired: string[] datasetTemplateInstructions?: DatasetTemplateInstruction[] + templateInstructionValues?: Record + onTemplateInstructionChange?: (instruction: TemplateInstructionInfo) => void + suppressInstructionEditor?: boolean + disableRequiredValidation?: boolean } export const MetadataFormField = ({ @@ -59,7 +75,11 @@ export const MetadataFormField = ({ compoundParentIsRequired, isFieldThatMayBecomeRequired, childFieldNamesThatTriggerRequired, - datasetTemplateInstructions + datasetTemplateInstructions, + templateInstructionValues, + onTemplateInstructionChange, + suppressInstructionEditor, + disableRequiredValidation }: DynamicMetadataFormFieldProps) => { const { name, @@ -97,10 +117,30 @@ export const MetadataFormField = ({ isParentFieldRequired: compoundParentIsRequired }) + const instructionFieldKey = MetadataFieldsHelper.replaceSlashWithDot(name) + + const requiredIndicator = disableRequiredValidation + ? Boolean(isRequired) + : Boolean(rulesToApply?.required) + + const validationRules = disableRequiredValidation + ? { ...rulesToApply, required: undefined } + : rulesToApply + const fieldInstructions: string | undefined = datasetTemplateInstructions?.find( - (i) => i.instructionField === MetadataFieldsHelper.replaceSlashWithDot(name) + (i) => i.instructionField === instructionFieldKey )?.instructionText + const instructionEditor = + onTemplateInstructionChange !== undefined && !suppressInstructionEditor + ? { + value: templateInstructionValues?.[instructionFieldKey]?.instructionText, + onSave: (instruction: TemplateInstructionInfo) => + onTemplateInstructionChange(instruction), + fieldKey: instructionFieldKey + } + : undefined + if (isSafePrimitive) { if (multiple) { return ( @@ -111,9 +151,12 @@ export const MetadataFormField = ({ watermark={watermark} displayName={displayName} description={description} - rulesToApply={rulesToApply} + rulesToApply={validationRules} + requiredIndicator={requiredIndicator} + disableRequiredValidation={disableRequiredValidation} metadataBlockName={metadataBlockName} fieldInstructions={fieldInstructions} + instructionEditor={instructionEditor} /> ) } @@ -125,7 +168,9 @@ export const MetadataFormField = ({ watermark={watermark} displayName={displayName} description={description} - rulesToApply={rulesToApply} + rulesToApply={validationRules} + requiredIndicator={requiredIndicator} + disableRequiredValidation={disableRequiredValidation} fieldsArrayIndex={fieldsArrayIndex} metadataBlockName={metadataBlockName} compoundParentName={compoundParentName} @@ -133,6 +178,7 @@ export const MetadataFormField = ({ isFieldThatMayBecomeRequired={isFieldThatMayBecomeRequired} childFieldNamesThatTriggerRequired={childFieldNamesThatTriggerRequired} fieldInstructions={fieldInstructions} + instructionEditor={instructionEditor} /> ) } @@ -147,11 +193,14 @@ export const MetadataFormField = ({ watermark={watermark} displayName={displayName} description={description} - rulesToApply={rulesToApply} + rulesToApply={validationRules} + requiredIndicator={requiredIndicator} + disableRequiredValidation={disableRequiredValidation} options={controlledVocabularyValues} compoundParentName={compoundParentName} metadataBlockName={metadataBlockName} fieldInstructions={fieldInstructions} + instructionEditor={instructionEditor} /> ) } @@ -163,13 +212,16 @@ export const MetadataFormField = ({ watermark={watermark} description={description} displayName={displayName} - rulesToApply={rulesToApply} + rulesToApply={validationRules} + requiredIndicator={requiredIndicator} + disableRequiredValidation={disableRequiredValidation} options={controlledVocabularyValues} fieldsArrayIndex={fieldsArrayIndex} metadataBlockName={metadataBlockName} compoundParentName={compoundParentName} withinMultipleFieldsGroup={withinMultipleFieldsGroup} fieldInstructions={fieldInstructions} + instructionEditor={instructionEditor} /> ) } @@ -184,12 +236,17 @@ export const MetadataFormField = ({ watermark={watermark} description={description} displayName={displayName} - rulesToApply={rulesToApply} + rulesToApply={validationRules} + requiredIndicator={requiredIndicator} + disableRequiredValidation={disableRequiredValidation} metadataBlockName={metadataBlockName} compoundParentName={compoundParentName} childMetadataFields={childMetadataFields} notRequiredWithChildFieldsRequired={notRequiredWithChildFieldsRequired} fieldInstructions={fieldInstructions} + instructionEditor={instructionEditor} + templateInstructionValues={templateInstructionValues} + onTemplateInstructionChange={onTemplateInstructionChange} /> ) } @@ -202,12 +259,17 @@ export const MetadataFormField = ({ watermark={watermark} description={description} displayName={displayName} - rulesToApply={rulesToApply} + rulesToApply={validationRules} + requiredIndicator={requiredIndicator} + disableRequiredValidation={disableRequiredValidation} metadataBlockName={metadataBlockName} compoundParentName={compoundParentName} childMetadataFields={childMetadataFields} notRequiredWithChildFieldsRequired={notRequiredWithChildFieldsRequired} fieldInstructions={fieldInstructions} + instructionEditor={instructionEditor} + templateInstructionValues={templateInstructionValues} + onTemplateInstructionChange={onTemplateInstructionChange} /> ) } diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx index 999bb6a29..dd1d6c511 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx @@ -1,13 +1,23 @@ import { DatasetTemplateInstruction } from '@/templates/domain/models/Template' +import { TemplateInstructionInfo } from '@/templates/domain/models/TemplateInfo' import { type MetadataBlockInfo } from '../../../../../../metadata-block-info/domain/models/MetadataBlockInfo' import { MetadataFormField } from './MetadataFormField' interface Props { metadataBlock: MetadataBlockInfo datasetTemplateInstructions?: DatasetTemplateInstruction[] + templateInstructionValues?: Record + onTemplateInstructionChange?: (instruction: TemplateInstructionInfo) => void + disableRequiredValidation?: boolean } -export const MetadataBlockFormFields = ({ metadataBlock, datasetTemplateInstructions }: Props) => { +export const MetadataBlockFormFields = ({ + metadataBlock, + datasetTemplateInstructions, + templateInstructionValues, + onTemplateInstructionChange, + disableRequiredValidation +}: Props) => { const { metadataFields, name: metadataBlockName } = metadataBlock return ( @@ -19,6 +29,9 @@ export const MetadataBlockFormFields = ({ metadataBlock, datasetTemplateInstruct metadataFieldInfo={metadataFieldInfo} metadataBlockName={metadataBlockName} datasetTemplateInstructions={datasetTemplateInstructions} + templateInstructionValues={templateInstructionValues} + onTemplateInstructionChange={onTemplateInstructionChange} + disableRequiredValidation={disableRequiredValidation} /> ) })} diff --git a/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx b/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx index 67d1498f8..b16584132 100644 --- a/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx +++ b/src/sections/shared/form/RequiredFieldText/RequiredFieldText.tsx @@ -1,13 +1,19 @@ import { Form, RequiredInputSymbol } from '@iqss/dataverse-design-system' import { useTranslation } from 'react-i18next' -export function RequiredFieldText() { +interface RequiredFieldTextProps { + i18nKey?: string +} + +export function RequiredFieldText({ + i18nKey = 'asterisksIndicateRequiredFields' +}: RequiredFieldTextProps) { const { t } = useTranslation('shared') return ( - {t('asterisksIndicateRequiredFields')} + {t(i18nKey)} ) } diff --git a/src/sections/shared/form/TemplateMetadataForm/TemplateForm/index.module.scss b/src/sections/shared/form/TemplateMetadataForm/TemplateForm/index.module.scss new file mode 100644 index 000000000..8c4b6631a --- /dev/null +++ b/src/sections/shared/form/TemplateMetadataForm/TemplateForm/index.module.scss @@ -0,0 +1,14 @@ +@import 'node_modules/bootstrap/scss/functions'; +@import 'node_modules/bootstrap/scss/variables'; + +.template-name-row { + margin-bottom: $spacer; +} + +.accordion { + margin-top: $spacer; +} + +.form-actions { + margin-top: $spacer; +} diff --git a/src/sections/shared/form/TemplateMetadataForm/TemplateForm/index.tsx b/src/sections/shared/form/TemplateMetadataForm/TemplateForm/index.tsx new file mode 100644 index 000000000..337072c8d --- /dev/null +++ b/src/sections/shared/form/TemplateMetadataForm/TemplateForm/index.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { FormProvider, useForm } from 'react-hook-form' +import { + Accordion, + Alert, + Button, + Col, + Form, + RequiredInputSymbol +} from '@iqss/dataverse-design-system' +import { + type MetadataBlockInfo, + type MetadataField +} from '@/metadata-block-info/domain/models/MetadataBlockInfo' +import { + MetadataFieldsHelper, + type DatasetMetadataFormValues +} from '../../DatasetMetadataForm/MetadataFieldsHelper' +import { MetadataBlockFormFields } from '../../DatasetMetadataForm/MetadataForm/MetadataBlockFormFields' +import { RequiredFieldText } from '../../RequiredFieldText/RequiredFieldText' +import { RouteWithParams } from '@/sections/Route.enum' +import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository' +import { useGetTemplatesByCollectionId } from '@/templates/domain/hooks/useGetTemplatesByCollectionId' +import { SubmissionStatus, useSubmitTemplate } from '../useSubmitTemplate' +import { TemplateInfo, TemplateInstructionInfo } from '@/templates/domain/models/TemplateInfo' +import styles from './index.module.scss' + +interface TemplateFormProps { + collectionId: string + templateRepository: TemplateRepository + metadataBlocksInfo: MetadataBlockInfo[] + formDefaultValues: DatasetMetadataFormValues + metadataFieldsForMapping: Record> +} + +export const TemplateForm = ({ + collectionId, + templateRepository, + metadataBlocksInfo, + formDefaultValues, + metadataFieldsForMapping +}: TemplateFormProps) => { + const { t } = useTranslation('datasetTemplates') + const navigate = useNavigate() + const [validationError, setValidationError] = useState(null) + const [templateName, setTemplateName] = useState('') + const [templateInstructions, setTemplateInstructions] = useState< + Record + >({}) + const { submissionStatus, submitError, submitTemplate } = useSubmitTemplate(collectionId) + + const { fetchDatasetTemplates } = useGetTemplatesByCollectionId({ + templateRepository, + collectionIdOrAlias: collectionId + }) + + const form = useForm({ mode: 'onChange', defaultValues: formDefaultValues }) + + useEffect(() => { + form.reset(formDefaultValues) + }, [form, formDefaultValues]) + + const handleTemplateInstructionChange = (instruction: TemplateInstructionInfo) => { + setTemplateInstructions((current) => { + const next = { ...current } + const { instructionField, instructionText } = instruction + if (!instructionText) { + delete next[instructionField] + return next + } + next[instructionField] = instruction + return next + }) + } + + const handleSaveAndAddTerms = () => { + if (!templateName.trim()) { + setValidationError(t('createTemplate.errors.nameRequired')) + return + } + + setValidationError(null) + + void form.handleSubmit(async (formValues) => { + const formValuesBackToDots = MetadataFieldsHelper.replaceSlashKeysWithDot(formValues) + const datasetDto = MetadataFieldsHelper.formatFormValuesToDatasetDTO( + formValuesBackToDots, + 'create' + ) + const templateFields = datasetDto.metadataBlocks.flatMap((metadataBlock) => + MetadataFieldsHelper.buildTemplateFieldsFromMetadataValues( + metadataBlock.fields, + metadataFieldsForMapping[metadataBlock.name] + ) + ) + + const instructions = Object.values(templateInstructions) + + const templatePayload: TemplateInfo = { + name: templateName.trim(), + fields: templateFields, + ...(instructions.length > 0 ? { instructions } : {}) + } + + const didSubmit = await submitTemplate(templatePayload) + if (!didSubmit) return + + const updatedTemplates = await fetchDatasetTemplates() + const normalizedName = templateName.trim().toLowerCase() + const createdTemplate = updatedTemplates.find( + (template) => template.name.trim().toLowerCase() === normalizedName + ) + + if (!createdTemplate) return + + navigate(RouteWithParams.TEMPLATES_EDIT_TERMS(collectionId, createdTemplate.id), { + state: { fromCreateTemplate: true } + }) + })() + } + + return ( + +
+ {submissionStatus === SubmissionStatus.SubmitComplete && ( + + {t('createTemplate.alerts.success')} + + )} + {(validationError ?? submitError) && ( + + {validationError ?? submitError} + + )} + + + {t('createTemplate.templateName')} + + + + { + const nextValue = event.target.value + setTemplateName(nextValue) + if (validationError && nextValue.trim()) { + setValidationError(null) + } + }} + /> + {validationError && ( + {validationError} + )} + + + + + + {metadataBlocksInfo.map((metadataBlock, index) => ( + + {metadataBlock.displayName} + + + + + ))} + +
+ + +
+ +
+ ) +} diff --git a/src/sections/shared/form/TemplateMetadataForm/TemplateMetadataForm.tsx b/src/sections/shared/form/TemplateMetadataForm/TemplateMetadataForm.tsx new file mode 100644 index 000000000..641a3ffbe --- /dev/null +++ b/src/sections/shared/form/TemplateMetadataForm/TemplateMetadataForm.tsx @@ -0,0 +1,78 @@ +import { useMemo } from 'react' +import { Alert } from '@iqss/dataverse-design-system' +import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository' +import { type MetadataField } from '@/metadata-block-info/domain/models/MetadataBlockInfo' +import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository' +import { useGetMetadataBlocksInfo } from '../DatasetMetadataForm/useGetMetadataBlocksInfo' +import { MetadataFieldsHelper } from '../DatasetMetadataForm/MetadataFieldsHelper' +import { MetadataFormSkeleton } from '../DatasetMetadataForm/MetadataForm/MetadataFormSkeleton' +import { TemplateForm } from './TemplateForm' + +interface TemplateMetadataFormProps { + collectionId: string + metadataBlockInfoRepository: MetadataBlockInfoRepository + templateRepository: TemplateRepository +} + +export const TemplateMetadataForm = ({ + collectionId, + metadataBlockInfoRepository, + templateRepository +}: TemplateMetadataFormProps) => { + const { + metadataBlocksInfo: metadataBlocksInfoForDisplay, + isLoading, + error + } = useGetMetadataBlocksInfo({ + mode: 'edit', + collectionId, + metadataBlockInfoRepository + }) + + const metadataBlocksInfo = useMemo( + () => + MetadataFieldsHelper.replaceMetadataBlocksInfoDotNamesKeysWithSlash( + metadataBlocksInfoForDisplay + ), + [metadataBlocksInfoForDisplay] + ) + + const metadataFieldsForMapping = useMemo( + () => + metadataBlocksInfoForDisplay.reduce>>( + (acc, block) => { + acc[block.name] = block.metadataFields ?? {} + return acc + }, + {} + ), + [metadataBlocksInfoForDisplay] + ) + + const formDefaultValues = useMemo( + () => MetadataFieldsHelper.getFormDefaultValues(metadataBlocksInfo), + [metadataBlocksInfo] + ) + + if (isLoading) { + return + } + + if (error) { + return ( + + {error} + + ) + } + + return ( + + ) +} diff --git a/src/sections/shared/form/TemplateMetadataForm/useSubmitTemplate.ts b/src/sections/shared/form/TemplateMetadataForm/useSubmitTemplate.ts new file mode 100644 index 000000000..7682209f8 --- /dev/null +++ b/src/sections/shared/form/TemplateMetadataForm/useSubmitTemplate.ts @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { WriteError, createTemplate } from '@iqss/dataverse-client-javascript' +import { TemplateInfo } from '@/templates/domain/models/TemplateInfo' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' + +export enum SubmissionStatus { + NotSubmitted = 'NotSubmitted', + IsSubmitting = 'IsSubmitting', + SubmitComplete = 'SubmitComplete', + Errored = 'Errored' +} + +type UseSubmitTemplateReturnType = + | { + submissionStatus: + | SubmissionStatus.NotSubmitted + | SubmissionStatus.IsSubmitting + | SubmissionStatus.SubmitComplete + submitTemplate: (payload: TemplateInfo) => Promise + submitError: null + } + | { + submissionStatus: SubmissionStatus.Errored + submitTemplate: (payload: TemplateInfo) => Promise + submitError: string + } + +export function useSubmitTemplate(collectionId: string): UseSubmitTemplateReturnType { + const { t } = useTranslation('datasetTemplates', { keyPrefix: 'createTemplate' }) + const [submissionStatus, setSubmissionStatus] = useState( + SubmissionStatus.NotSubmitted + ) + const [submitError, setSubmitError] = useState(null) + + const submitTemplate = async (template: TemplateInfo): Promise => { + setSubmissionStatus(SubmissionStatus.IsSubmitting) + setSubmitError(null) + + try { + await createTemplate.execute( + template as Parameters[0], + collectionId + ) + setSubmissionStatus(SubmissionStatus.SubmitComplete) + return true + } catch (error) { + if (error instanceof WriteError) { + const handler = new JSDataverseWriteErrorHandler(error) + const formattedError = + handler.getReasonWithoutStatusCode() ?? + /* istanbul ignore next */ handler.getErrorMessage() + setSubmitError(formattedError) + } else { + setSubmitError(t('errors.saveFailed')) + } + setSubmissionStatus(SubmissionStatus.Errored) + return false + } + } + + return { + submissionStatus, + submitTemplate, + submitError + } as UseSubmitTemplateReturnType +} diff --git a/src/sections/templates/DatasetTemplates.module.scss b/src/sections/templates/DatasetTemplates.module.scss new file mode 100644 index 000000000..c64f995e7 --- /dev/null +++ b/src/sections/templates/DatasetTemplates.module.scss @@ -0,0 +1,142 @@ +@import 'node_modules/bootstrap/scss/functions'; +@import 'node_modules/bootstrap/scss/variables'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.header { + margin-bottom: $spacer; +} + +.breadcrumb { + margin-bottom: $spacer; +} + +.header-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; +} + +.subtext { + color: $dv-subtext-color; +} + +.table-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin: $spacer 0; +} + +.table-actions-left { + flex: 1; +} + +.include-templates-filter { + margin-bottom: 0; +} + +@media (max-width: 576px) { + .table-actions { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .table-actions-left { + width: 100%; + } + + .create-button { + width: 100%; + justify-content: center; + } +} + +.create-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.action-group { + flex-wrap: wrap; +} + +.action-group .btn { + min-height: 2.25rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +td { + text-align: center; + vertical-align: middle; +} + +.template-origin { + display: block; + color: $dv-subtext-color; + font-style: italic; + white-space: nowrap; + overflow: hidden; + word-break: break-word; +} + +.make-default-button { + margin-right: 0.75rem; +} + +.name-column { + width: 32%; +} + +.action-column { + width: 32%; +} + +.sort-button { + padding: 0; + text-decoration: none; + color: inherit; + background: none; + border: none; + display: inline-flex; + align-items: center; + font-size: inherit; + font-weight: inherit; +} + +.sort-button:focus, +.sort-button:active, +.sort-button:focus-visible { + color: inherit; + box-shadow: none; + opacity: 1; +} + +.sort-button:hover { + color: inherit; +} + +.sort-button-active { + color: $dv-subtext-color; +} + +.sort-header-active { + background-color: rgba($dv-subtext-color, 0.08); + color: $dv-subtext-color; +} + +.sort-icon { + display: inline-flex; + vertical-align: middle; +} + +.empty-state { + color: $dv-subtext-color; + padding: 2rem; + border: 1px solid rgba($dv-subtext-color, 0.2); + border-radius: 0.5rem; +} diff --git a/src/sections/templates/DatasetTemplates.tsx b/src/sections/templates/DatasetTemplates.tsx new file mode 100644 index 000000000..b2b232ced --- /dev/null +++ b/src/sections/templates/DatasetTemplates.tsx @@ -0,0 +1,422 @@ +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { + Alert, + Button, + ButtonGroup, + DropdownButton, + DropdownButtonItem, + Form, + Table, + Tooltip +} from '@iqss/dataverse-design-system' +import { + CaretDown, + CaretUp, + ChevronExpand, + CheckLg, + Eye, + Files, + Pencil, + PlusLg, + Trash +} from 'react-bootstrap-icons' +import { toast } from 'react-toastify' +import { RouteWithParams } from '@/sections/Route.enum' +import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' +import { DatasetTemplatesSkeleton } from './DatasetTemplatesSkeleton' +import { useGetCollectionUserPermissions } from '@/shared/hooks/useGetCollectionUserPermissions' +import { DatasetTemplatesEmptyState } from './DatasetTemplatesEmptyState' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository' +import { TemplateRepository } from '@/templates/domain/repositories/TemplateRepository' +import { useCollection } from '../collection/useCollection' +import { useGetTemplatesByCollectionId } from '@/templates/domain/hooks/useGetTemplatesByCollectionId' +import { NotFoundPage } from '../not-found-page/NotFoundPage' +import { NotImplementedModal } from '../not-implemented/NotImplementedModal' +import { useNotImplementedModal } from '../not-implemented/NotImplementedModalContext' +import { Template } from '@/templates/domain/models/Template' +import { ConfirmDeleteTemplateModal } from './confirm-delete-template-modal/ConfirmDeleteTemplateModal' +import { TemplatePreviewModal } from './template-preview-modal/TemplatePreviewModal' +import { useCopyTemplate } from './useCopyTemplate' + +import styles from './DatasetTemplates.module.scss' + +interface DatasetTemplatesProps { + collectionRepository: CollectionRepository + templateRepository: TemplateRepository + metadataBlockInfoRepository: MetadataBlockInfoRepository + collectionId: string +} + +export const DatasetTemplates = ({ + collectionRepository, + templateRepository, + metadataBlockInfoRepository, + collectionId +}: DatasetTemplatesProps) => { + const { t } = useTranslation('datasetTemplates') + const { t: tDataset } = useTranslation('dataset') + const navigate = useNavigate() + const { isModalOpen, hideModal, showModal } = useNotImplementedModal() + const [sortBy, setSortBy] = useState<'name' | 'created' | 'usage' | null>(null) + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') + const [templateToDelete, setTemplateToDelete] = useState