From 900f7fe51282920a512787f8bcdac8707e255300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Tue, 2 Sep 2025 15:25:27 -0300 Subject: [PATCH 01/24] feat: initial setup --- package-lock.json | 10 +-- package.json | 2 +- .../domain/hooks/useGetDatasetTemplates.ts | 63 +++++++++++++++++++ src/dataset/domain/models/DatasetTemplate.ts | 29 +++++++++ .../domain/repositories/DatasetRepository.ts | 2 + .../domain/useCases/getDatasetTemplates.ts | 9 +++ .../DatasetJSDataverseRepository.ts | 8 ++- 7 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/dataset/domain/hooks/useGetDatasetTemplates.ts create mode 100644 src/dataset/domain/models/DatasetTemplate.ts create mode 100644 src/dataset/domain/useCases/getDatasetTemplates.ts diff --git a/package-lock.json b/package-lock.json index e6c59d54b..679f6505a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.62", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.6c9fb0e", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.62", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.62/61ac45ca90983c86af8adb443bc013ea929db0ad", - "integrity": "sha512-BtblnMfg6a0m6E8bbcwkZ7aEOBHeLzRbkaJVcKFybxWz33h76Xjr1TIOKcd9cwGNoaAFxReigGGp8EWmWwqehA==", + "version": "2.0.0-pr355.6c9fb0e", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.6c9fb0e/72992e5bf2e62ed66788437841c010c97cba87e8", + "integrity": "sha512-52fufH4MeQYppOLZYa7gDMd8R6sjgw/wzeZP6eIgG3QJTVW9zMDCiRwUWiQA78liufmWtdmngA3t2EVtys5xmw==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", @@ -43950,4 +43950,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index d65f4e87a..6c62538f1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.62", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.6c9fb0e", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/src/dataset/domain/hooks/useGetDatasetTemplates.ts b/src/dataset/domain/hooks/useGetDatasetTemplates.ts new file mode 100644 index 000000000..32c656459 --- /dev/null +++ b/src/dataset/domain/hooks/useGetDatasetTemplates.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from 'react' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' +import { DatasetRepository } from '../repositories/DatasetRepository' +import { DatasetTemplate } from '../models/DatasetTemplate' +import { getDatasetTemplates } from '../useCases/getDatasetTemplates' + +interface useGetDatasetTemplatesProps { + datasetRepository: DatasetRepository + collectionIdOrAlias: number | string + autoFetch?: boolean +} + +export const useGetDatasetTemplates = ({ + datasetRepository, + collectionIdOrAlias, + autoFetch = true +}: useGetDatasetTemplatesProps) => { + const [datasetTemplates, setDatasetTemplates] = useState([]) + const [isLoadingDatasetTemplates, setIsLoadingDatasetTemplates] = useState(autoFetch) + const [errorGetDatasetTemplates, setErrorGetDatasetTemplates] = useState(null) + + const fetchDatasetTemplates = useCallback(async () => { + setIsLoadingDatasetTemplates(true) + setErrorGetDatasetTemplates(null) + + try { + const response: DatasetTemplate[] = await getDatasetTemplates( + datasetRepository, + collectionIdOrAlias + ) + + setDatasetTemplates(response) + } catch (err) { + if (err instanceof ReadError) { + const error = new JSDataverseReadErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + + setErrorGetDatasetTemplates(formattedError) + } else { + setErrorGetDatasetTemplates( + 'Something went wrong getting the dataset templates. Try again later.' + ) + } + } finally { + setIsLoadingDatasetTemplates(false) + } + }, [datasetRepository, collectionIdOrAlias]) + + useEffect(() => { + if (autoFetch) { + void fetchDatasetTemplates() + } + }, [autoFetch, fetchDatasetTemplates]) + + return { + datasetTemplates, + isLoadingDatasetTemplates, + errorGetDatasetTemplates, + fetchDatasetTemplates + } +} diff --git a/src/dataset/domain/models/DatasetTemplate.ts b/src/dataset/domain/models/DatasetTemplate.ts new file mode 100644 index 000000000..60d05450d --- /dev/null +++ b/src/dataset/domain/models/DatasetTemplate.ts @@ -0,0 +1,29 @@ +import { DatasetLicense, DatasetMetadataFieldValue, DatasetTermsOfUse } from './Dataset' + +export interface DatasetTemplate { + id: number + name: string + alias: string + isDefault: boolean + usageCount: number + createTime: string + createDate: string + // πŸ‘‡ From Edit Template Metadata + datasetFields: DatasetFields + instructions: DatasetTemplateInstruction[] + // πŸ‘‡ From Edit Template Terms + termsOfUse: DatasetTermsOfUse + license?: DatasetLicense // This license property is going to be present if not custom terms are added in the UI +} + +type DatasetFields = Record + +interface DatasetFieldInfo { + displayName: string + name: string + fields: DatasetMetadataFieldValue[] +} +export interface DatasetTemplateInstruction { + instructionField: string + instructionText: string +} diff --git a/src/dataset/domain/repositories/DatasetRepository.ts b/src/dataset/domain/repositories/DatasetRepository.ts index 17e4d5670..b87314506 100644 --- a/src/dataset/domain/repositories/DatasetRepository.ts +++ b/src/dataset/domain/repositories/DatasetRepository.ts @@ -8,6 +8,7 @@ import { DatasetVersionSummaryInfo } from '../models/DatasetVersionSummaryInfo' import { DatasetDeaccessionDTO } from '../useCases/DTOs/DatasetDTO' import { DatasetDownloadCount } from '../models/DatasetDownloadCount' import { FormattedCitation, CitationFormat } from '../models/DatasetCitation' +import { DatasetTemplate } from '../models/DatasetTemplate' export interface DatasetRepository { getByPersistentId: ( @@ -54,4 +55,5 @@ export interface DatasetRepository { version: string, format: CitationFormat ) => Promise + getTemplates: (collectionIdOrAlias: number | string) => Promise } diff --git a/src/dataset/domain/useCases/getDatasetTemplates.ts b/src/dataset/domain/useCases/getDatasetTemplates.ts new file mode 100644 index 000000000..806c26af4 --- /dev/null +++ b/src/dataset/domain/useCases/getDatasetTemplates.ts @@ -0,0 +1,9 @@ +import { DatasetRepository } from '../repositories/DatasetRepository' +import { DatasetTemplate } from '../models/DatasetTemplate' + +export function getDatasetTemplates( + datasetRepository: DatasetRepository, + collectionIdOrAlias: number | string +): Promise { + return datasetRepository.getTemplates(collectionIdOrAlias) +} diff --git a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts index c1519b352..8d7d73d51 100644 --- a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts +++ b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts @@ -33,7 +33,8 @@ import { getDatasetDownloadCount, deleteDatasetDraft, getDatasetCitationInOtherFormats, - getDatasetAvailableCategories + getDatasetAvailableCategories, + getDatasetTemplates } from '@iqss/dataverse-client-javascript' import { JSDatasetMapper } from '../mappers/JSDatasetMapper' import { DatasetPaginationInfo } from '../../domain/models/DatasetPaginationInfo' @@ -49,6 +50,7 @@ import { axiosInstance } from '@/axiosInstance' import { DATAVERSE_BACKEND_URL } from '../../../config' import { AxiosResponse } from 'axios' import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' +import { DatasetTemplate } from '@/dataset/domain/models/DatasetTemplate' const includeDeaccessioned = true @@ -396,6 +398,10 @@ export class DatasetJSDataverseRepository implements DatasetRepository { return getDatasetAvailableCategories.execute(datasetId) } + getTemplates(collectionIdOrAlias: number | string): Promise { + return getDatasetTemplates.execute(collectionIdOrAlias) + } + /* TODO: This is a temporary solution as this use case doesn't exist in js-dataverse yet and the API should also return the file store type rather than name only. After https://github.com/IQSS/dataverse/issues/11695 is implemented, create a js-dataverse use case. From 3115101af93278566241564a9f8e3b0bda9d8437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Tue, 2 Sep 2025 16:20:09 -0300 Subject: [PATCH 02/24] feat: add unit test for custom hook --- src/dataset/domain/models/DatasetTemplate.ts | 2 +- .../hooks/useGetDatasetTemplates.spec.ts | 78 +++++++++++++++++++ .../domain/models/DatasetTemplateMother.ts | 25 ++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/component/dataset/domain/hooks/useGetDatasetTemplates.spec.ts create mode 100644 tests/component/dataset/domain/models/DatasetTemplateMother.ts diff --git a/src/dataset/domain/models/DatasetTemplate.ts b/src/dataset/domain/models/DatasetTemplate.ts index 60d05450d..ab11935e8 100644 --- a/src/dataset/domain/models/DatasetTemplate.ts +++ b/src/dataset/domain/models/DatasetTemplate.ts @@ -3,7 +3,7 @@ import { DatasetLicense, DatasetMetadataFieldValue, DatasetTermsOfUse } from './ export interface DatasetTemplate { id: number name: string - alias: string + collectionAlias: string isDefault: boolean usageCount: number createTime: string diff --git a/tests/component/dataset/domain/hooks/useGetDatasetTemplates.spec.ts b/tests/component/dataset/domain/hooks/useGetDatasetTemplates.spec.ts new file mode 100644 index 000000000..7aefc79ee --- /dev/null +++ b/tests/component/dataset/domain/hooks/useGetDatasetTemplates.spec.ts @@ -0,0 +1,78 @@ +import { act, renderHook } from '@testing-library/react' +import { DatasetTemplateMother } from '../models/DatasetTemplateMother' +import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { useGetDatasetTemplates } from '@/dataset/domain/hooks/useGetDatasetTemplates' +import { ReadError } from '@iqss/dataverse-client-javascript' + +const datasetRepository: DatasetRepository = {} as DatasetRepository +const datasetTemplatesMock = DatasetTemplateMother.createMany(3) + +describe('useGetDatasetTemplates', () => { + it('should return dataset templates', async () => { + datasetRepository.getTemplates = cy.stub().resolves(datasetTemplatesMock) + + const { result } = renderHook(() => + useGetDatasetTemplates({ + datasetRepository, + collectionIdOrAlias: 'collection-alias' + }) + ) + + await act(() => { + expect(result.current.isLoadingDatasetTemplates).to.deep.equal(true) + return expect(result.current.datasetTemplates).to.deep.equal([]) + }) + + await act(() => { + expect(result.current.isLoadingDatasetTemplates).to.deep.equal(false) + + return expect(result.current.datasetTemplates).to.deep.equal(datasetTemplatesMock) + }) + }) + + describe('Error handling', () => { + it('should return correct error message when it is a ReadError instance from js-dataverse', async () => { + datasetRepository.getTemplates = cy.stub().rejects(new ReadError('Error message')) + + const { result } = renderHook(() => + useGetDatasetTemplates({ + datasetRepository, + collectionIdOrAlias: 'collection-alias' + }) + ) + + await act(() => { + expect(result.current.isLoadingDatasetTemplates).to.deep.equal(true) + return expect(result.current.errorGetDatasetTemplates).to.deep.equal(null) + }) + + await act(() => { + expect(result.current.isLoadingDatasetTemplates).to.deep.equal(false) + return expect(result.current.errorGetDatasetTemplates).to.deep.equal('Error message') + }) + }) + + it('should return correct default error message when it is not a ReadError instance from js-dataverse', async () => { + datasetRepository.getTemplates = cy.stub().rejects('Error message') + + const { result } = renderHook(() => + useGetDatasetTemplates({ + datasetRepository, + collectionIdOrAlias: 'collection-alias' + }) + ) + + await act(() => { + expect(result.current.isLoadingDatasetTemplates).to.deep.equal(true) + return expect(result.current.errorGetDatasetTemplates).to.deep.equal(null) + }) + + await act(() => { + expect(result.current.isLoadingDatasetTemplates).to.deep.equal(false) + return expect(result.current.errorGetDatasetTemplates).to.deep.equal( + 'Something went wrong getting the dataset templates. Try again later.' + ) + }) + }) + }) +}) diff --git a/tests/component/dataset/domain/models/DatasetTemplateMother.ts b/tests/component/dataset/domain/models/DatasetTemplateMother.ts new file mode 100644 index 000000000..02b938bec --- /dev/null +++ b/tests/component/dataset/domain/models/DatasetTemplateMother.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker' +import { DatasetTemplate } from '@/dataset/domain/models/DatasetTemplate' + +export class DatasetTemplateMother { + static createMany(count: number, props?: Partial): DatasetTemplate[] { + return Array.from({ length: count }, () => this.create(props)) + } + static create(props?: Partial): DatasetTemplate { + return { + id: faker.datatype.number({ min: 1 }), + name: faker.lorem.words(3), + collectionAlias: faker.lorem.word({ length: { min: 3, max: 15 } }), + createTime: 'Tue Sep 02 13:13:47 UTC 2025', + createDate: 'Sep 2, 2025', + datasetFields: {}, + isDefault: faker.datatype.boolean(), + usageCount: faker.datatype.number({ min: 0, max: 100 }), + instructions: [], + termsOfUse: { + termsOfAccess: { fileAccessRequest: false } + }, + ...props + } + } +} From 10b5d1998c056f92a1f8d26dbb306983430deb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 3 Sep 2025 08:26:51 -0300 Subject: [PATCH 03/24] fix: remove unneeded loading check on button --- .../form/DatasetMetadataForm/MetadataForm/index.tsx | 11 ++--------- .../shared/form/DatasetMetadataForm/index.tsx | 1 - 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx index 17d3dfc71..f69f2ef28 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx @@ -21,7 +21,6 @@ interface FormProps { collectionId: string formDefaultValues: DatasetMetadataFormValues metadataBlocksInfo: MetadataBlockInfo[] - errorLoadingMetadataBlocksInfo: string | null datasetRepository: DatasetRepository datasetPersistentID?: string datasetInternalVersionNumber?: number @@ -32,7 +31,6 @@ export const MetadataForm = ({ collectionId, formDefaultValues, metadataBlocksInfo, - errorLoadingMetadataBlocksInfo, datasetRepository, datasetPersistentID, datasetInternalVersionNumber @@ -46,7 +44,6 @@ export const MetadataForm = ({ const onCreateMode = mode === 'create' const onEditMode = mode === 'edit' - const isErrorLoadingMetadataBlocks = Boolean(errorLoadingMetadataBlocksInfo) const form = useForm({ mode: 'onChange', defaultValues: formDefaultValues }) const { setValue, formState } = form @@ -121,12 +118,8 @@ export const MetadataForm = ({ } const disableSubmitButton = useMemo(() => { - return ( - isErrorLoadingMetadataBlocks || - submissionStatus === SubmissionStatus.IsSubmitting || - !formState.isDirty - ) - }, [isErrorLoadingMetadataBlocks, submissionStatus, formState.isDirty]) + return submissionStatus === SubmissionStatus.IsSubmitting || !formState.isDirty + }, [submissionStatus, formState.isDirty]) const preventEnterSubmit = (e: React.KeyboardEvent) => { // When pressing Enter, only submit the form if the user is focused on the submit button itself diff --git a/src/sections/shared/form/DatasetMetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/index.tsx index 4ebd7aaab..a0638b658 100644 --- a/src/sections/shared/form/DatasetMetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/index.tsx @@ -112,7 +112,6 @@ export const DatasetMetadataForm = ({ collectionId={collectionId} formDefaultValues={formDefaultValues} metadataBlocksInfo={normalizedMetadataBlocksInfo} - errorLoadingMetadataBlocksInfo={errorLoadingMetadataBlocksInfo} datasetRepository={datasetRepository} datasetPersistentID={datasetPersistentID} datasetInternalVersionNumber={datasetInternalVersionNumber} From 897ade16c7255a325d35871e75e68c20d7dd859b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 3 Sep 2025 12:40:53 -0300 Subject: [PATCH 04/24] feat(DesignSystem): support for value and label options --- packages/design-system/CHANGELOG.md | 4 +- .../select-advanced/SelectAdvanced.tsx | 178 ++++++++---------- .../select-advanced/SelectAdvancedMenu.tsx | 97 +++++----- .../select-advanced/SelectAdvancedToggle.tsx | 45 +++-- .../select-advanced/selectAdvancedReducer.ts | 139 +++++--------- .../lib/components/select-advanced/utils.ts | 31 +-- .../select-advanced/SelectAdvanced.spec.tsx | 141 ++++++++++++++ .../selectAdvancedReducer.spec.tsx | 117 ++++++------ .../component/select-advanced/utils.spec.ts | 84 ++++++--- 9 files changed, 486 insertions(+), 350 deletions(-) diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 30e620d7a..cf365fb94 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -11,7 +11,9 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - Add `align` prop to control the alignment of the dropdown menu. - **DropdownButtonItem:** - Add `type` prop to allow specifying the type of the element. -- **SelectAdvanced:** Fix word wrapping in options list to prevent overflow and ensure long text is displayed correctly. +- **SelectAdvanced:** + - Fix word wrapping in options list to prevent overflow and ensure long text is displayed correctly. + - Support for options with a shape of `{ label: string; value: string; }[]` instead of just `string[]`. # [2.0.2](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@2.0.1...@iqss/dataverse-design-system@2.0.2) (2024-06-23) diff --git a/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.tsx b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.tsx index 641430b5d..82520c7c1 100644 --- a/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.tsx +++ b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.tsx @@ -12,47 +12,43 @@ import { } from './selectAdvancedReducer' import { SelectAdvancedToggle } from './SelectAdvancedToggle' import { SelectAdvancedMenu } from './SelectAdvancedMenu' -import { areArraysEqual, debounce } from './utils' +import { areOptionArraysEqual, debounce, normalizeOptions } from './utils' import { useIsFirstRender } from './useIsFirstRender' -export const DEFAULT_LOCALES = { - select: 'Select...' +export const DEFAULT_LOCALES = { select: 'Select...' } +export const SELECT_MENU_SEARCH_DEBOUNCE_TIME = 400 + +export type Option = { value: string; label: string } + +export type InputOptions = string[] | Option[] + +type BaseProps = { + options: InputOptions + isSearchable?: boolean + isDisabled?: boolean + isInvalid?: boolean + inputButtonId?: string + locales?: { select?: string } } -export const SELECT_MENU_SEARCH_DEBOUNCE_TIME = 400 +type SingleProps = BaseProps & { + isMultiple?: false + onChange?: (selected: string) => void + defaultValue?: string +} -export type SelectAdvancedProps = - | { - isMultiple?: false - options: string[] - onChange?: (selected: string) => void - defaultValue?: string - isSearchable?: boolean - isDisabled?: boolean - isInvalid?: boolean - inputButtonId?: string - locales?: { - select?: string - } - } - | { - isMultiple: true - options: string[] - onChange?: (selected: string[]) => void - defaultValue?: string[] - isSearchable?: boolean - isDisabled?: boolean - isInvalid?: boolean - inputButtonId?: string - locales?: { - select?: string - } - } +type MultipleProps = BaseProps & { + isMultiple: true + onChange?: (selected: string[]) => void + defaultValue?: string[] +} + +export type SelectAdvancedProps = SingleProps | MultipleProps export const SelectAdvanced = forwardRef( ( { - options: propsOption, + options: propsOptions, onChange, defaultValue, isMultiple, @@ -64,15 +60,16 @@ export const SelectAdvanced = forwardRef( }: SelectAdvancedProps, ref: ForwardedRef ) => { - const dynamicInitialOptions = useMemo(() => { - return isMultiple ? propsOption : [locales?.select ?? DEFAULT_LOCALES.select, ...propsOption] - }, [isMultiple, propsOption, locales]) + const normalizedOptions: Option[] = useMemo( + () => normalizeOptions(propsOptions), + [propsOptions] + ) const [{ selected, filteredOptions, searchValue, options }, dispatch] = useReducer( selectAdvancedReducer, getSelectAdvancedInitialState( Boolean(isMultiple), - dynamicInitialOptions, + normalizedOptions, locales?.select ?? DEFAULT_LOCALES.select, defaultValue ) @@ -81,107 +78,89 @@ export const SelectAdvanced = forwardRef( const isFirstRender = useIsFirstRender() const menuId = useId() - const callOnChage = useCallback( + const callOnChange = useCallback( (newSelected: string | string[]): void => { if (!onChange) return - //@ts-expect-error - types differs + // @ts-expect-error - union narrowing en runtime onChange(newSelected) }, [onChange] ) useEffect(() => { - const optionsRemainTheSame = areArraysEqual(dynamicInitialOptions, options) - - // If the options remain the same, do nothing + const optionsRemainTheSame = areOptionArraysEqual(normalizedOptions, options) if (optionsRemainTheSame) return - const selectedOptionsThatAreNotInNewOptions = isMultiple - ? (selected as string[]).filter((option) => !dynamicInitialOptions.includes(option)) - : [] - - // If there are selected options that are not in the new options, remove them - if (isMultiple && selectedOptionsThatAreNotInNewOptions.length > 0) { - selectedOptionsThatAreNotInNewOptions.forEach((option) => dispatch(removeOption(option))) - - const newSelected = (selected as string[]).filter((option) => - dynamicInitialOptions.includes(option) - ) + const optionValues = new Set(normalizedOptions.map((o) => o.value)) - callOnChage(newSelected) + if (isMultiple) { + const selectedValues = selected as string[] + const outOfNewOptions = selectedValues.filter((v) => !optionValues.has(v)) + if (outOfNewOptions.length > 0) { + const newSelected = selectedValues.filter((v) => optionValues.has(v)) + callOnChange(newSelected) + outOfNewOptions.forEach((v) => dispatch(removeOption(v))) + } + } else { + const current = selected as string + if (current !== '' && !optionValues.has(current)) { + dispatch(selectOption('')) + callOnChange('') + } } - // If the selected option is not in the new options replace it with the default empty value - if ( - !isMultiple && - selected !== '' && - !dynamicInitialOptions.some((option) => option === (selected as string)) - ) { - dispatch(selectOption('')) - callOnChage('') - } - dispatch(updateOptions(dynamicInitialOptions)) - }, [dynamicInitialOptions, options, selected, isFirstRender, dispatch, callOnChage, isMultiple]) + dispatch(updateOptions(normalizedOptions)) + }, [normalizedOptions, options, selected, isFirstRender, callOnChange, isMultiple]) const handleSearch = debounce((e: React.ChangeEvent): void => { const { value } = e.target dispatch(searchOptions(value)) }, SELECT_MENU_SEARCH_DEBOUNCE_TIME) - // ONLY FOR MULTIPLE SELECT πŸ‘‡ + // MULTIPLE const handleCheck = (e: React.ChangeEvent): void => { const { value, checked } = e.target - if (checked) { const newSelected = [...(selected as string[]), value] - callOnChage(newSelected) - + callOnChange(newSelected) dispatch(selectOption(value)) } else { - const newSelected = (selected as string[]).filter((option) => option !== value) - callOnChage(newSelected) - + const newSelected = (selected as string[]).filter((v) => v !== value) + callOnChange(newSelected) dispatch(removeOption(value)) } } - // ONLY FOR SINGLE SELECT πŸ‘‡ - const handleClickOption = (option: string): void => { - if ((selected as string) === option) { - return - } - callOnChage(option) - - dispatch(selectOption(option)) + // SINGLE + const handleClickOption = (value: string): void => { + if ((selected as string) === value) return + callOnChange(value) + dispatch(selectOption(value)) } - // ONLY FOR MULTIPLE SELECT πŸ‘‡ - const handleRemoveSelectedOption = (option: string): void => { - const newSelected = (selected as string[]).filter((selected) => selected !== option) - callOnChage(newSelected) - - dispatch(removeOption(option)) + // MULTIPLE + const handleRemoveSelectedOption = (value: string): void => { + const newSelected = (selected as string[]).filter((v) => v !== value) + callOnChange(newSelected) + dispatch(removeOption(value)) } - // ONLY FOR MULTIPLE SELECT πŸ‘‡ + // MULTIPLE const handleToggleAllOptions = (e: React.ChangeEvent): void => { if (e.target.checked) { - const newSelected = - filteredOptions.length > 0 - ? Array.from(new Set([...(selected as string[]), ...filteredOptions])) - : options - - callOnChage(newSelected) - + const source = filteredOptions.length > 0 ? filteredOptions : options + const newSelected = Array.from( + new Set([...(selected as string[]), ...source.map((o) => o.value)]) + ) + callOnChange(newSelected) dispatch(selectAllOptions()) } else { + const toRemove = new Set( + (filteredOptions.length > 0 ? filteredOptions : options).map((o) => o.value) + ) const newSelected = - filteredOptions.length > 0 - ? (selected as string[]).filter((option) => !filteredOptions.includes(option)) - : [] - - callOnChage(newSelected) - + filteredOptions.length > 0 ? (selected as string[]).filter((v) => !toRemove.has(v)) : [] + callOnChange(newSelected) dispatch(deselectAllOptions()) } } @@ -193,6 +172,7 @@ export const SelectAdvanced = forwardRef( ) => void handleSearch: (e: React.ChangeEvent) => void handleCheck: (e: React.ChangeEvent) => void - handleClickOption: (option: string) => void + handleClickOption: (value: string) => void isSearchable: boolean menuId: string selectWord: string } -export const SelectAdvancedMenu = ({ - isMultiple, - options, - selected, - filteredOptions, - searchValue, - handleToggleAllOptions, - handleSearch, - handleCheck, - handleClickOption, - isSearchable, - menuId, - selectWord -}: SelectAdvancedMenuProps) => { +export const SelectAdvancedMenu = (props: SelectAdvancedMenuProps) => { + const { + isMultiple, + options, + selected, + filteredOptions, + searchValue, + handleToggleAllOptions, + handleSearch, + handleCheck, + handleClickOption, + isSearchable, + menuId, + selectWord + } = props + const searchInputControlID = useId() const toggleAllControlID = useId() const optionLabelId = useId() const menuOptions = filteredOptions.length > 0 ? filteredOptions : options - const noOptionsFound = searchValue !== '' && filteredOptions.length === 0 + const selectedArray = Array.isArray(selected) ? selected : [selected] const allOptionsShownAreSelected = !noOptionsFound - ? filteredOptions.length > 0 - ? filteredOptions.every((option) => selected.includes(option)) - : options.every((option) => selected.includes(option)) + ? menuOptions.length > 0 && menuOptions.every((o) => selectedArray.includes(o.value)) : false return ( @@ -50,16 +51,7 @@ export const SelectAdvancedMenu = ({ as="menu" id={menuId} className={styles['select-advanced-menu']} - popperConfig={{ - modifiers: [ - { - name: 'offset', - options: { - offset: () => [0, 0] - } - } - ] - }}> + popperConfig={{ modifiers: [{ name: 'offset', options: { offset: () => [0, 0] } }] }}> {(isMultiple || isSearchable) && ( {isMultiple && ( @@ -84,28 +76,43 @@ export const SelectAdvancedMenu = ({ data-testid="select-advanced-searchable-input" /> )} + {isMultiple && !isSearchable && (

- {selected.length} selected + {selectedArray.filter(Boolean).length} selected

)}
)} + {!isMultiple && searchValue === '' && ( + handleClickOption('')} + active={selected === ''} + key="__placeholder__"> + {selectWord} + + )} + {!noOptionsFound && - menuOptions.map((option) => { + menuOptions.map((opt) => { if (!isMultiple) { return ( handleClickOption(option === selectWord ? '' : option)} - active={option !== selectWord ? selected === option : selected === ''} - key={option}> - {option} + onClick={() => handleClickOption(opt.value)} + active={selected === opt.value} + key={opt.value}> + {opt.label} ) } @@ -115,15 +122,15 @@ export const SelectAdvancedMenu = ({ as="li" className={styles['option-item']} role="option" - data-value={option} - key={option}> + data-value={opt.value} + key={opt.value}> diff --git a/packages/design-system/src/lib/components/select-advanced/SelectAdvancedToggle.tsx b/packages/design-system/src/lib/components/select-advanced/SelectAdvancedToggle.tsx index 80876268d..015c168fd 100644 --- a/packages/design-system/src/lib/components/select-advanced/SelectAdvancedToggle.tsx +++ b/packages/design-system/src/lib/components/select-advanced/SelectAdvancedToggle.tsx @@ -1,12 +1,14 @@ -import { ForwardedRef, forwardRef } from 'react' +import { ForwardedRef, forwardRef, useMemo } from 'react' import { Dropdown as DropdownBS, Button as ButtonBS } from 'react-bootstrap' import { X as CloseIcon } from 'react-bootstrap-icons' +import { Option } from './SelectAdvanced' import styles from './SelectAdvanced.module.scss' type SelectAdvancedToggleProps = { isMultiple: boolean selected: string | string[] - handleRemoveSelectedOption: (option: string) => void + options?: Option[] + handleRemoveSelectedOption: (value: string) => void isInvalid?: boolean isDisabled?: boolean inputButtonId?: string @@ -19,6 +21,7 @@ export const SelectAdvancedToggle = forwardRef( { isMultiple, selected, + options = [], handleRemoveSelectedOption, isInvalid, isDisabled, @@ -28,6 +31,9 @@ export const SelectAdvancedToggle = forwardRef( }: SelectAdvancedToggleProps, ref: ForwardedRef ) => { + const map = useMemo(() => new Map(options.map((o) => [o.value, o.label])), [options]) + const selectedArray = Array.isArray(selected) ? selected : selected ? [selected] : [] + return (
@@ -48,31 +54,34 @@ export const SelectAdvancedToggle = forwardRef(
- {selected.length > 0 ? ( + {selectedArray.length > 0 ? (
{isMultiple ? ( - (selected as string[]).map((selectedValue) => ( -
e.stopPropagation()} - key={`selected-option-${selectedValue}`}> - {selectedValue} - handleRemoveSelectedOption(selectedValue)}> - - -
- )) + selectedArray.map((val) => { + const label = map.get(val) ?? val + return ( +
e.stopPropagation()} + key={`selected-option-${val}`}> + {label} + handleRemoveSelectedOption(val)}> + + +
+ ) + }) ) : (

- {selected} + {map.get(selected as string) ?? (selected as string)}

)}
diff --git a/packages/design-system/src/lib/components/select-advanced/selectAdvancedReducer.ts b/packages/design-system/src/lib/components/select-advanced/selectAdvancedReducer.ts index 6f95f7dc9..36e022e13 100644 --- a/packages/design-system/src/lib/components/select-advanced/selectAdvancedReducer.ts +++ b/packages/design-system/src/lib/components/select-advanced/selectAdvancedReducer.ts @@ -1,6 +1,8 @@ +import { Option } from './SelectAdvanced' + export const getSelectAdvancedInitialState = ( isMultiple: boolean, - initialOptions: string[], + initialOptions: Option[], selectWord: string, defaultValue?: string | string[] ): SelectAdvancedState => ({ @@ -17,138 +19,101 @@ export const getSelectAdvancedInitialState = ( export interface SelectAdvancedState { isMultiple: boolean selected: string | string[] - options: string[] - filteredOptions: string[] + options: Option[] + filteredOptions: Option[] searchValue: string selectWord: string } type SelectAdvancedActions = - | { - type: 'SELECT_OPTION' - payload: string - } - | { - type: 'REMOVE_OPTION' - payload: string - } - | { - type: 'SELECT_ALL_OPTIONS' - } - | { - type: 'DESELECT_ALL_OPTIONS' - } - | { - type: 'SEARCH' - payload: string - } - | { - type: 'UPDATE_OPTIONS' - payload: string[] - } + | { type: 'SELECT_OPTION'; payload: string } + | { type: 'REMOVE_OPTION'; payload: string } + | { type: 'SELECT_ALL_OPTIONS' } + | { type: 'DESELECT_ALL_OPTIONS' } + | { type: 'SEARCH'; payload: string } + | { type: 'UPDATE_OPTIONS'; payload: Option[] } export const selectAdvancedReducer = ( state: SelectAdvancedState, action: SelectAdvancedActions -) => { +): SelectAdvancedState => { switch (action.type) { case 'SELECT_OPTION': if (state.isMultiple) { return { ...state, - selected: [...state.selected, action.payload] - } - } else { - return { - ...state, - selected: action.payload + selected: Array.from(new Set([...(state.selected as string[]), action.payload])) } } - // ONLY FOR MULTIPLE SELECT πŸ‘‡ + return { ...state, selected: action.payload } + case 'REMOVE_OPTION': return { ...state, - selected: (state.selected as string[]).filter((option) => option !== action.payload) + selected: (state.selected as string[]).filter((v) => v !== action.payload) } - // ONLY FOR MULTIPLE SELECT πŸ‘‡ - case 'SELECT_ALL_OPTIONS': + case 'SELECT_ALL_OPTIONS': { + const source = state.filteredOptions.length > 0 ? state.filteredOptions : state.options + const allValues = source.map((o) => o.value) return { ...state, - selected: - state.filteredOptions.length > 0 - ? Array.from(new Set([...(state.selected as string[]), ...state.filteredOptions])) - : state.options + selected: state.isMultiple + ? Array.from(new Set([...(state.selected as string[]), ...allValues])) + : (state.selected as string) } - // ONLY FOR MULTIPLE SELECT πŸ‘‡ - case 'DESELECT_ALL_OPTIONS': - return { - ...state, - selected: - state.filteredOptions.length > 0 - ? (state.selected as string[]).filter( - (option) => !state.filteredOptions.includes(option) - ) - : [] + } + + case 'DESELECT_ALL_OPTIONS': { + if (state.filteredOptions.length > 0) { + const toRemove = new Set(state.filteredOptions.map((o) => o.value)) + return { + ...state, + selected: (state.selected as string[]).filter((v) => !toRemove.has(v)) + } } + return { ...state, selected: [] } + } + case 'SEARCH': return { ...state, - filteredOptions: filterOptions(state, action), + filteredOptions: filterOptions(state, action.payload), searchValue: action.payload } + case 'UPDATE_OPTIONS': - return { - ...state, - options: action.payload - } + return { ...state, options: action.payload } + default: return state } } -const filterOptions = ( - state: SelectAdvancedState, - action: { - type: 'SEARCH' - payload: string - } -) => { - if (action.payload === '') return [] - - const optionsWithoutSelectWord = state.options.filter((option) => option !== state.selectWord) - - return optionsWithoutSelectWord.filter((option) => - option.toLowerCase().includes(action.payload.toLowerCase()) +const filterOptions = (state: SelectAdvancedState, query: string): Option[] => { + if (query.trim() === '') return [] + const q = query.toLowerCase() + return state.options.filter( + (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) ) } -export const selectOption = /* istanbul ignore next */ (option: string): SelectAdvancedActions => ({ +// Action creators +export const selectOption = (value: string): SelectAdvancedActions => ({ type: 'SELECT_OPTION', - payload: option + payload: value }) - -export const removeOption = /* istanbul ignore next */ (option: string): SelectAdvancedActions => ({ +export const removeOption = (value: string): SelectAdvancedActions => ({ type: 'REMOVE_OPTION', - payload: option -}) - -export const selectAllOptions = /* istanbul ignore next */ (): SelectAdvancedActions => ({ - type: 'SELECT_ALL_OPTIONS' -}) - -export const deselectAllOptions = /* istanbul ignore next */ (): SelectAdvancedActions => ({ - type: 'DESELECT_ALL_OPTIONS' + payload: value }) - -export const searchOptions = /* istanbul ignore next */ (value: string): SelectAdvancedActions => ({ +export const selectAllOptions = (): SelectAdvancedActions => ({ type: 'SELECT_ALL_OPTIONS' }) +export const deselectAllOptions = (): SelectAdvancedActions => ({ type: 'DESELECT_ALL_OPTIONS' }) +export const searchOptions = (value: string): SelectAdvancedActions => ({ type: 'SEARCH', payload: value }) - -export const updateOptions = /* istanbul ignore next */ ( - options: string[] -): SelectAdvancedActions => ({ +export const updateOptions = (options: Option[]): SelectAdvancedActions => ({ type: 'UPDATE_OPTIONS', payload: options }) diff --git a/packages/design-system/src/lib/components/select-advanced/utils.ts b/packages/design-system/src/lib/components/select-advanced/utils.ts index 709120843..9595f64b6 100644 --- a/packages/design-system/src/lib/components/select-advanced/utils.ts +++ b/packages/design-system/src/lib/components/select-advanced/utils.ts @@ -1,3 +1,5 @@ +import { InputOptions, Option } from './SelectAdvanced' + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function debounce unknown>( fn: T, @@ -12,23 +14,22 @@ export function debounce unknown>( } } -export function areArraysEqual(arr1: string[], arr2: string[]): boolean { - if (arr1.length === 0 && arr2.length === 0) { - return true - } - - if (arr1.length !== arr2.length) { - return false +// Normalize to Option[] +export function normalizeOptions(input: InputOptions): Option[] { + if (!input) return [] + if (typeof input[0] === 'string' || input.length === 0) { + return (input as string[]).map((s) => ({ value: s, label: s })) } + return input as Option[] +} - const sortedArr1 = arr1.slice().sort() - const sortedArr2 = arr2.slice().sort() - - for (let i = 0; i < sortedArr1.length; i++) { - if (sortedArr1[i] !== sortedArr2[i]) { - return false - } +// Checks equality by content (value+label) regardless of order +export function areOptionArraysEqual(a: Option[], b: Option[]): boolean { + if (a.length !== b.length) return false + const A = [...a].sort((x, y) => x.value.localeCompare(y.value)) + const B = [...b].sort((x, y) => x.value.localeCompare(y.value)) + for (let i = 0; i < A.length; i++) { + if (A[i].value !== B[i].value || A[i].label !== B[i].label) return false } - return true } diff --git a/packages/design-system/tests/component/select-advanced/SelectAdvanced.spec.tsx b/packages/design-system/tests/component/select-advanced/SelectAdvanced.spec.tsx index 45623e59d..014979f71 100644 --- a/packages/design-system/tests/component/select-advanced/SelectAdvanced.spec.tsx +++ b/packages/design-system/tests/component/select-advanced/SelectAdvanced.spec.tsx @@ -8,6 +8,15 @@ function toggleOptionsMenu() { cy.findByLabelText('Toggle options menu').click() } +const vlOptions = [ + { value: 'reading', label: 'Reading' }, + { value: 'swimming', label: 'Swimming' }, + { value: 'running', label: 'Running' }, + { value: 'cycling', label: 'Cycling' }, + { value: 'cooking', label: 'Cooking' }, + { value: 'gardening', label: 'Gardening' } +] + describe('SelectAdvanced', () => { describe('should render correctly', () => { it('on single selection', () => { @@ -19,6 +28,12 @@ describe('SelectAdvanced', () => { cy.findByText('Select...').should('exist') }) + + it('on single selection (value/label)', () => { + cy.mount() + cy.findByText('Select...').should('exist') + }) + it('on multiple selection', () => { cy.mount( { ) cy.findByText('Select...').should('exist') }) + + it('on multiple selection (value/label)', () => { + cy.mount() + cy.findByText('Select...').should('exist') + }) }) describe('should render correct options', () => { @@ -51,6 +71,7 @@ describe('SelectAdvanced', () => { // 6 Options + 1 Select... option cy.findAllByRole('option').should('have.length', 7) }) + it('on multiple selection', () => { cy.mount( { cy.findAllByRole('option').should('have.length', 6) }) + + it('on single selection (value/label)', () => { + cy.mount() + + toggleOptionsMenu() + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Cycling').should('exist') + cy.findByText('Cooking').should('exist') + cy.findByText('Gardening').should('exist') + + // 6 options + 1 Select... option + cy.findAllByRole('option').should('have.length', 7) + }) + + it('on multiple selection (value/label)', () => { + cy.mount() + + toggleOptionsMenu() + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Cycling').should('exist') + cy.findByText('Cooking').should('exist') + cy.findByText('Gardening').should('exist') + + cy.findAllByRole('option').should('have.length', 6) + }) }) describe('should render with default values', () => { @@ -93,6 +143,18 @@ describe('SelectAdvanced', () => { cy.findByText('Reading').should('exist') cy.findByText('Running').should('exist') }) + it('on single selection (value/label)', () => { + cy.mount() + cy.findByText('Running').should('exist') // shows label + }) + + it('on multiple selection (value/label)', () => { + cy.mount( + + ) + cy.findByText('Reading').should('exist') + cy.findByText('Running').should('exist') + }) }) describe('should call onChange when an option is selected', () => { @@ -129,6 +191,28 @@ describe('SelectAdvanced', () => { cy.get('@onChange').should('have.been.calledOnce') cy.get('@onChange').should('have.been.calledWith', ['Reading']) }) + + it('on single selection (value/label)', () => { + const onChange = cy.stub().as('onChange') + cy.mount() + + toggleOptionsMenu() + cy.findByText('Reading').click() + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', 'reading') + }) + + it('on multiple selection (value/label)', () => { + const onChange = cy.stub().as('onChange') + cy.mount() + + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', ['reading']) + }) }) describe('should call onChange when an option is deselected', () => { @@ -169,6 +253,20 @@ describe('SelectAdvanced', () => { cy.get('@onChange').should('have.been.calledOnce') cy.get('@onChange').should('have.been.calledWith', ['Running']) }) + + it('on multiple selection (value/label)', () => { + const onChange = cy.stub().as('onChange') + cy.mount() + + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + cy.get('@onChange').should('have.been.calledWith', ['reading']) + + cy.findByLabelText('Reading').click() + cy.get('@onChange').should('have.been.calledWith', []) + + cy.get('@onChange').should('have.been.calledTwice') + }) }) describe('should not call onChange when passing defaultValues and rendering for first time', () => { @@ -198,6 +296,25 @@ describe('SelectAdvanced', () => { ) cy.get('@onChange').should('not.have.been.called') }) + + it('on single selection (value/label)', () => { + const onChange = cy.stub().as('onChange') + cy.mount() + cy.get('@onChange').should('not.have.been.called') + }) + + it('on multiple selection (value/label)', () => { + const onChange = cy.stub().as('onChange') + cy.mount( + + ) + cy.get('@onChange').should('not.have.been.called') + }) }) describe('should call onChange correct times after multiple types of selections', () => { @@ -596,6 +713,30 @@ describe('SelectAdvanced', () => { cy.findByLabelText('Cooking').should('not.exist') cy.findByLabelText('Gardening').should('not.exist') }) + + it('on single selection (value/label) - by label', () => { + cy.mount() + + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('Read') + + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('not.exist') + }) + + it('on multiple selection (value/label) - by value', () => { + cy.mount() + cy.clock() + + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('swimming') // search by value + cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) + + cy.findByLabelText('Swimming').should('exist') + cy.findByLabelText('Reading').should('not.exist') + + cy.clock().then((clock) => clock.restore()) + }) }) it('should debounce the search input correctly', () => { diff --git a/packages/design-system/tests/component/select-advanced/selectAdvancedReducer.spec.tsx b/packages/design-system/tests/component/select-advanced/selectAdvancedReducer.spec.tsx index a405df864..d106369b6 100644 --- a/packages/design-system/tests/component/select-advanced/selectAdvancedReducer.spec.tsx +++ b/packages/design-system/tests/component/select-advanced/selectAdvancedReducer.spec.tsx @@ -1,16 +1,19 @@ +import { type Option } from '../../../src/lib/components/select-advanced/SelectAdvanced' import { SelectAdvancedState, getSelectAdvancedInitialState, selectAdvancedReducer } from '../../../src/lib/components/select-advanced/selectAdvancedReducer' +import { normalizeOptions } from '../../../src/lib/components/select-advanced/utils' -const options = ['Reading', 'Swimming', 'Running'] +const stringOptions = ['Reading', 'Swimming', 'Running'] +const options: Option[] = normalizeOptions(stringOptions) const selectWord = 'Select...' describe('selectAdvancedReducer', () => { it('should return state if bad action type is passed', () => { const expectedInitialState: SelectAdvancedState = { - options: options, + options, selected: '', filteredOptions: [], searchValue: '', @@ -18,33 +21,29 @@ describe('selectAdvancedReducer', () => { selectWord } - const state = selectAdvancedReducer(getSelectAdvancedInitialState(false, options, selectWord), { + const state = selectAdvancedReducer( + getSelectAdvancedInitialState(false, options, selectWord), // @ts-expect-error - Testing bad action type - type: 'BAD_ACTION' - }) + { type: 'BAD_ACTION' } + ) - expect(state).deep.equal(expectedInitialState) + expect(state).to.deep.equal(expectedInitialState) }) describe('should select an option', () => { it('on single select mode', () => { const state = selectAdvancedReducer( getSelectAdvancedInitialState(false, options, selectWord), - { - type: 'SELECT_OPTION', - payload: 'Reading' - } + { type: 'SELECT_OPTION', payload: 'Reading' } ) - expect(state.selected).to.include('Reading') + expect(state.selected).to.equal('Reading') }) + it('on multiple select mode', () => { const state = selectAdvancedReducer( getSelectAdvancedInitialState(true, options, selectWord), - { - type: 'SELECT_OPTION', - payload: 'Reading' - } + { type: 'SELECT_OPTION', payload: 'Reading' } ) expect(state.selected).to.include('Reading') @@ -54,128 +53,120 @@ describe('selectAdvancedReducer', () => { it('should remove an option', () => { const state = selectAdvancedReducer( { ...getSelectAdvancedInitialState(true, options, selectWord), selected: ['Reading'] }, - { - type: 'REMOVE_OPTION', - payload: 'Reading' - } + { type: 'REMOVE_OPTION', payload: 'Reading' } ) expect(state.selected).to.not.include('Reading') }) it('should select all available options when there are no current filtered options', () => { + const localOptions = normalizeOptions(['Reading', 'Swimming']) const state = selectAdvancedReducer( - { - ...getSelectAdvancedInitialState(true, options, selectWord), - options: ['Reading', 'Swimming'] - }, - { - type: 'SELECT_ALL_OPTIONS' - } + { ...getSelectAdvancedInitialState(true, options, selectWord), options: localOptions }, + { type: 'SELECT_ALL_OPTIONS' } ) expect(state.selected).to.deep.equal(['Reading', 'Swimming']) }) it('should deselect all available options when there are no current filtered options', () => { + const localOptions = normalizeOptions(['Reading', 'Swimming']) const state = selectAdvancedReducer( { ...getSelectAdvancedInitialState(true, options, selectWord), - options: ['Reading', 'Swimming'], + options: localOptions, selected: ['Reading', 'Swimming'] }, - { - type: 'DESELECT_ALL_OPTIONS' - } + { type: 'DESELECT_ALL_OPTIONS' } ) expect(state.selected).to.be.empty }) it('should select all filtered options', () => { + const localOptions = normalizeOptions(['Reading', 'Swimming', 'Running']) + const filtered = normalizeOptions(['Reading', 'Swimming']) // reducer expects Option[] const state = selectAdvancedReducer( { ...getSelectAdvancedInitialState(true, options, selectWord), - options: ['Reading', 'Swimming', 'Running'], - filteredOptions: ['Reading', 'Swimming'] + options: localOptions, + filteredOptions: filtered }, - { - type: 'SELECT_ALL_OPTIONS' - } + { type: 'SELECT_ALL_OPTIONS' } ) expect(state.selected).to.deep.equal(['Reading', 'Swimming']) }) it('should deselect all filtered options', () => { + const localOptions = normalizeOptions(['Reading', 'Swimming', 'Running']) + const filtered = normalizeOptions(['Reading', 'Swimming']) const state = selectAdvancedReducer( { ...getSelectAdvancedInitialState(true, options, selectWord), - options: ['Reading', 'Swimming', 'Running'], + options: localOptions, selected: ['Reading', 'Swimming'], - filteredOptions: ['Reading', 'Swimming'] + filteredOptions: filtered }, - { - type: 'DESELECT_ALL_OPTIONS' - } + { type: 'DESELECT_ALL_OPTIONS' } ) expect(state.selected).to.be.empty }) it('should add filtered options to selected options when selecting all if filtered options are present', () => { + const localOptions = normalizeOptions(['Reading', 'Swimming', 'Running']) + const filtered = normalizeOptions(['Running']) const state = selectAdvancedReducer( { ...getSelectAdvancedInitialState(true, options, selectWord), - options: ['Reading', 'Swimming', 'Running'], + options: localOptions, selected: ['Reading', 'Swimming'], - filteredOptions: ['Running'] + filteredOptions: filtered }, - { - type: 'SELECT_ALL_OPTIONS' - } + { type: 'SELECT_ALL_OPTIONS' } ) - expect(state.selected).to.deep.equal(['Reading', 'Swimming', 'Running']) + expect(state.selected).to.have.members(['Reading', 'Swimming', 'Running']) + expect(state.selected).to.have.length(3) }) it('should filter options', () => { + const localOptions = normalizeOptions(['Reading', 'Swimming', 'Running']) const state = selectAdvancedReducer( { ...getSelectAdvancedInitialState(true, options, selectWord), - options: ['Reading', 'Swimming', 'Running'] + options: localOptions }, - { - type: 'SEARCH', - payload: 'read' - } + { type: 'SEARCH', payload: 'read' } ) - expect(state.filteredOptions).to.include('Reading') - expect(state.filteredOptions).to.not.include('Swimming', 'Running') + // filteredOptions es Option[], chequeamos por value + const filteredValues = state.filteredOptions.map((o) => o.value) + expect(filteredValues).to.include('Reading') + expect(filteredValues).to.not.include('Swimming') + expect(filteredValues).to.not.include('Running') }) it('should reset search value when empty string is passed', () => { const state = selectAdvancedReducer( { ...getSelectAdvancedInitialState(true, options, selectWord), searchValue: 'read' }, - { - type: 'SEARCH', - payload: '' - } + { type: 'SEARCH', payload: '' } ) expect(state.searchValue).to.equal('') + // AdemΓ‘s, por implementaciΓ³n actual, filteredOptions vuelve a [] + expect(state.filteredOptions).to.deep.equal([]) }) it('should update options', () => { + const initial = normalizeOptions(['Reading']) + const updated = normalizeOptions(['Reading', 'Swimming']) const state = selectAdvancedReducer( - { ...getSelectAdvancedInitialState(true, options, selectWord), options: ['Reading'] }, - { - type: 'UPDATE_OPTIONS', - payload: ['Reading', 'Swimming'] - } + { ...getSelectAdvancedInitialState(true, options, selectWord), options: initial }, + { type: 'UPDATE_OPTIONS', payload: updated } ) - expect(state.options).to.deep.equal(['Reading', 'Swimming']) + expect(state.options).to.deep.equal(updated) }) }) diff --git a/packages/design-system/tests/component/select-advanced/utils.spec.ts b/packages/design-system/tests/component/select-advanced/utils.spec.ts index 3d411f5bc..19f9a1897 100644 --- a/packages/design-system/tests/component/select-advanced/utils.spec.ts +++ b/packages/design-system/tests/component/select-advanced/utils.spec.ts @@ -1,37 +1,77 @@ import chai from 'chai' import chaiAsPromised from 'chai-as-promised' -import { areArraysEqual } from '../../../src/lib/components/select-advanced/utils' +import { + areOptionArraysEqual, + normalizeOptions +} from '../../../src/lib/components/select-advanced/utils' chai.use(chaiAsPromised) const expect = chai.expect describe('utils', () => { - describe('areArraysEqual', () => { - it('should return true if arrays are equal', () => { - const case1 = areArraysEqual([], []) - const case2 = areArraysEqual( - ['Option 1', 'Option 2', 'Option 3'], - ['Option 1', 'Option 2', 'Option 3'] + describe('normalizeOptions', () => { + it('should normalize string[] into Option[]', () => { + const input = ['Option 1', 'Option 2'] + const result = normalizeOptions(input) + expect(result).to.deep.equal([ + { value: 'Option 1', label: 'Option 1' }, + { value: 'Option 2', label: 'Option 2' } + ]) + }) + + it('should keep Option[] as-is', () => { + const input = [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' } + ] + const result = normalizeOptions(input) + expect(result).to.deep.equal(input) + }) + + it('should return [] for empty input', () => { + expect(normalizeOptions([])).to.deep.equal([]) + }) + }) + + describe('areOptionArraysEqual', () => { + it('should return true if arrays are equal (order-insensitive)', () => { + const case1 = areOptionArraysEqual([], []) + const case2 = areOptionArraysEqual( + normalizeOptions(['Option 1', 'Option 2', 'Option 3']), + normalizeOptions(['Option 1', 'Option 2', 'Option 3']) + ) + const case3 = areOptionArraysEqual( + normalizeOptions(['Option 1', 'Option 2', 'Option 3']), + normalizeOptions(['Option 1', 'Option 3', 'Option 2']) ) - const case3 = areArraysEqual( - ['Option 1', 'Option 2', 'Option 3'], - ['Option 1', 'Option 3', 'Option 2'] + const case4 = areOptionArraysEqual( + normalizeOptions(['0', '1', '2', '10']), + normalizeOptions(['10', '1', '0', '2']) ) - const case4 = areArraysEqual(['0', '1', '2', '10'], ['10', '1', '0', '2']) - expect(case1).to.be.equal(true) - expect(case2).to.be.equal(true) - expect(case3).to.be.equal(true) - expect(case4).to.be.equal(true) + expect(case1).to.equal(true) + expect(case2).to.equal(true) + expect(case3).to.equal(true) + expect(case4).to.equal(true) }) - it('should return false if arrays are not equal', () => { - const case1 = areArraysEqual(['Option 1'], ['Option 1', 'Option 2']) - const case2 = areArraysEqual( - ['Option 1', 'Option 2', 'Option 3'], - ['Option 1', 'Option 2', 'Option 4'] + + it('should return false if arrays differ by content or length', () => { + const case1 = areOptionArraysEqual( + normalizeOptions(['Option 1']), + normalizeOptions(['Option 1', 'Option 2']) ) - expect(case1).to.be.equal(false) - expect(case2).to.be.equal(false) + const case2 = areOptionArraysEqual( + normalizeOptions(['Option 1', 'Option 2', 'Option 3']), + normalizeOptions(['Option 1', 'Option 2', 'Option 4']) + ) + const case3 = areOptionArraysEqual( + [{ value: 'a', label: 'A' }], + [{ value: 'a', label: 'Different Label' }] + ) + + expect(case1).to.equal(false) + expect(case2).to.equal(false) + expect(case3).to.equal(false) }) }) }) From 20e1db4144568b38a5343373c3d59e260d5f62a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 3 Sep 2025 15:50:30 -0300 Subject: [PATCH 05/24] feat: select template logic --- package-lock.json | 8 +-- package.json | 2 +- public/locales/en/createDataset.json | 7 ++- src/sections/create-dataset/CreateDataset.tsx | 46 ++++++++++++++-- .../DatasetTemplateSelect.tsx | 55 +++++++++++++++++++ .../dataset/DatasetErrorMockRepository.ts | 9 +++ src/stories/dataset/DatasetMockRepository.ts | 10 ++++ 7 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx diff --git a/package-lock.json b/package-lock.json index 679f6505a..767e4ad77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.6c9fb0e", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.9eb5ab8", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-pr355.6c9fb0e", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.6c9fb0e/72992e5bf2e62ed66788437841c010c97cba87e8", - "integrity": "sha512-52fufH4MeQYppOLZYa7gDMd8R6sjgw/wzeZP6eIgG3QJTVW9zMDCiRwUWiQA78liufmWtdmngA3t2EVtys5xmw==", + "version": "2.0.0-pr355.9eb5ab8", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.9eb5ab8/3471d12c0ed307ea8364aff698ee193c8d61edc7", + "integrity": "sha512-J/ERug3WHeix/72Z/u/C8qW3NhWqW/UrOd3EyYasUGTWDYGhvkXn4GCyHO50YQ+5zgM8uDvWdVO4/5gd4706Uw==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 6c62538f1..23a03405d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.6c9fb0e", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.9eb5ab8", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/public/locales/en/createDataset.json b/public/locales/en/createDataset.json index bc6a311c4..d9900dd7c 100644 --- a/public/locales/en/createDataset.json +++ b/public/locales/en/createDataset.json @@ -6,5 +6,10 @@ "helpText": "Changing the host collection will clear any fields you may have entered data into.", "buttonLabel": "Edit Host Collection" }, - "notAllowedToCreateDataset": "You do not have permissions to create a dataset within this collection." + "notAllowedToCreateDataset": "You do not have permissions to create a dataset within this collection.", + "template": { + "label": "Dataset Template", + "description": "The dataset template which prepopulates info into the form automatically.", + "helpText": "Changing the template will clear any fields you may have entered data into." + } } diff --git a/src/sections/create-dataset/CreateDataset.tsx b/src/sections/create-dataset/CreateDataset.tsx index b262140b2..5fd3db78a 100644 --- a/src/sections/create-dataset/CreateDataset.tsx +++ b/src/sections/create-dataset/CreateDataset.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from '@iqss/dataverse-design-system' import { type DatasetRepository } from '../../dataset/domain/repositories/DatasetRepository' @@ -11,11 +11,13 @@ import { DatasetMetadataForm } from '../shared/form/DatasetMetadataForm' import { useGetCollectionUserPermissions } from '../../shared/hooks/useGetCollectionUserPermissions' import { CollectionRepository } from '../../collection/domain/repositories/CollectionRepository' import { useLoading } from '../loading/LoadingContext' - import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { useCollection } from '../collection/useCollection' import { NotFoundPage } from '../not-found-page/NotFoundPage' import { CreateDatasetSkeleton } from './CreateDatasetSkeleton' +import { useGetDatasetTemplates } from '@/dataset/domain/hooks/useGetDatasetTemplates' +import { type DatasetTemplate } from '@/dataset/domain/models/DatasetTemplate' +import { DatasetTemplateSelect } from './dataset-template-select/DatasetTemplateSelect' interface CreateDatasetProps { datasetRepository: DatasetRepository @@ -33,6 +35,7 @@ export function CreateDataset({ const { t } = useTranslation('createDataset') const { isModalOpen, hideModal } = useNotImplementedModal() const { setIsLoading } = useLoading() + const [selectedTemplate, setSelectedTemplate] = useState(null) const { collection, isLoading: isLoadingCollection } = useCollection( collectionRepository, @@ -46,17 +49,41 @@ export function CreateDataset({ }) const canUserAddDataset = Boolean(collectionUserPermissions?.canAddDataset) - const isLoadingData = isLoadingCollectionUserPermissions || isLoadingCollection + + const { datasetTemplates, isLoadingDatasetTemplates, errorGetDatasetTemplates } = + useGetDatasetTemplates({ + datasetRepository, + collectionIdOrAlias: collectionId + }) + + const handleDatasetTemplateChange = (selectedTemplateId: string) => { + const template: DatasetTemplate | null = + datasetTemplates.find((template) => template.id.toString() === selectedTemplateId) || null + setSelectedTemplate(template) + } + + const isLoadingData = + isLoadingCollectionUserPermissions || isLoadingCollection || isLoadingDatasetTemplates useEffect(() => { setIsLoading(isLoadingData) }, [isLoadingData, setIsLoading]) + // When dataset templates are loaded we set the default one if any + useEffect(() => { + if (datasetTemplates.length > 0) { + const defaultTemplate: DatasetTemplate | null = + datasetTemplates.find((template) => template.isDefault) || null + + setSelectedTemplate(defaultTemplate) + } + }, [datasetTemplates]) + if (!isLoadingCollection && !collection) { return } - if (isLoadingCollection || !collection) { + if (isLoadingData || !collection) { return } @@ -85,11 +112,22 @@ export function CreateDataset({ + {datasetTemplates.length > 0 && ( + + )} + + {/* If there is an error loading dataset templates we notify the user but dont block them from creating a dataset */} + {errorGetDatasetTemplates && {errorGetDatasetTemplates}} + diff --git a/src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx b/src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx new file mode 100644 index 000000000..0db9c2b04 --- /dev/null +++ b/src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Col, Form } from '@iqss/dataverse-design-system' +import { DatasetTemplate } from '@/dataset/domain/models/DatasetTemplate' + +interface DatasetTemplateSelectProps { + datasetTemplates: DatasetTemplate[] + onChange: (selectedTemplateId: string) => void +} + +export const DatasetTemplateSelect = ({ + datasetTemplates, + onChange +}: DatasetTemplateSelectProps) => { + const { t } = useTranslation('createDataset') + + const options = useMemo( + () => + datasetTemplates.map((template) => ({ + label: template.name, + value: template.id.toString() + })), + [datasetTemplates] + ) + + // Find the default template if any and use it as the default value if no template is selected + const defaultTemplate: DatasetTemplate | null = useMemo( + () => datasetTemplates.find((template) => template.isDefault) || null, + [datasetTemplates] + ) + + return ( + + + {t('template.label')} + + + {t('template.helpText')} + + + + ) +} diff --git a/src/stories/dataset/DatasetErrorMockRepository.ts b/src/stories/dataset/DatasetErrorMockRepository.ts index 9745e0014..bde43004d 100644 --- a/src/stories/dataset/DatasetErrorMockRepository.ts +++ b/src/stories/dataset/DatasetErrorMockRepository.ts @@ -10,6 +10,7 @@ import { DatasetVersionSummaryInfo } from '@/dataset/domain/models/DatasetVersio import { DatasetDeaccessionDTO } from '@iqss/dataverse-client-javascript' import { DatasetDownloadCount } from '@/dataset/domain/models/DatasetDownloadCount' import { CitationFormat, FormattedCitation } from '@/dataset/domain/models/DatasetCitation' +import { DatasetTemplate } from '@/dataset/domain/models/DatasetTemplate' export class DatasetErrorMockRepository implements DatasetMockRepository { getAllWithCount: ( @@ -147,4 +148,12 @@ export class DatasetErrorMockRepository implements DatasetMockRepository { }, FakerHelper.loadingTimout()) }) } + + getTemplates(_collectionIdOrAlias: number | string): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject('Error thrown from mock') + }, FakerHelper.loadingTimout()) + }) + } } diff --git a/src/stories/dataset/DatasetMockRepository.ts b/src/stories/dataset/DatasetMockRepository.ts index c54271d9a..c34cc164f 100644 --- a/src/stories/dataset/DatasetMockRepository.ts +++ b/src/stories/dataset/DatasetMockRepository.ts @@ -15,6 +15,8 @@ import { DatasetDeaccessionDTO } from '@iqss/dataverse-client-javascript' import { DatasetDownloadCount } from '@/dataset/domain/models/DatasetDownloadCount' import { DatasetDownloadCountMother } from '@tests/component/dataset/domain/models/DatasetDownloadCountMother' import { CitationFormat, FormattedCitation } from '@/dataset/domain/models/DatasetCitation' +import { DatasetTemplate } from '@/dataset/domain/models/DatasetTemplate' +import { DatasetTemplateMother } from '@tests/component/dataset/domain/models/DatasetTemplateMother' export class DatasetMockRepository implements DatasetRepository { getAllWithCount: ( @@ -161,4 +163,12 @@ export class DatasetMockRepository implements DatasetRepository { }, FakerHelper.loadingTimout()) }) } + + getTemplates(_collectionIdOrAlias: number | string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(DatasetTemplateMother.createMany(3)) + }, FakerHelper.loadingTimout()) + }) + } } From 06dda56b2bb6e21a6fe9fac42f10296545805a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 4 Sep 2025 11:16:31 -0300 Subject: [PATCH 06/24] feat: populating fields working --- package-lock.json | 8 +-- package.json | 2 +- src/dataset/domain/models/DatasetTemplate.ts | 11 +--- src/sections/create-dataset/CreateDataset.tsx | 1 + .../shared/form/DatasetMetadataForm/index.tsx | 59 +++++++++++++++++-- 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 767e4ad77..935369faf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.9eb5ab8", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.70276ca", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-pr355.9eb5ab8", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.9eb5ab8/3471d12c0ed307ea8364aff698ee193c8d61edc7", - "integrity": "sha512-J/ERug3WHeix/72Z/u/C8qW3NhWqW/UrOd3EyYasUGTWDYGhvkXn4GCyHO50YQ+5zgM8uDvWdVO4/5gd4706Uw==", + "version": "2.0.0-pr355.70276ca", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.70276ca/d33665f9e17225b86445857074d02a72ccb9f9d5", + "integrity": "sha512-D1q1ISnwVcr5C1P9QDa74QGzjWnBZ6Ys0Wda2/JTqIhUQevBEagA2ZAAIaQD/BSN/+ntYGsWg7Eu2XvRc/5bZw==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 23a03405d..30e61e202 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.9eb5ab8", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.70276ca", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/src/dataset/domain/models/DatasetTemplate.ts b/src/dataset/domain/models/DatasetTemplate.ts index ab11935e8..9b8c1cfea 100644 --- a/src/dataset/domain/models/DatasetTemplate.ts +++ b/src/dataset/domain/models/DatasetTemplate.ts @@ -1,4 +1,4 @@ -import { DatasetLicense, DatasetMetadataFieldValue, DatasetTermsOfUse } from './Dataset' +import { DatasetLicense, DatasetMetadataBlocks, DatasetTermsOfUse } from './Dataset' export interface DatasetTemplate { id: number @@ -9,20 +9,13 @@ export interface DatasetTemplate { createTime: string createDate: string // πŸ‘‡ From Edit Template Metadata - datasetFields: DatasetFields + datasetMetadataBlocks: DatasetMetadataBlocks instructions: DatasetTemplateInstruction[] // πŸ‘‡ From Edit Template Terms termsOfUse: DatasetTermsOfUse license?: DatasetLicense // This license property is going to be present if not custom terms are added in the UI } -type DatasetFields = Record - -interface DatasetFieldInfo { - displayName: string - name: string - fields: DatasetMetadataFieldValue[] -} export interface DatasetTemplateInstruction { instructionField: string instructionText: string diff --git a/src/sections/create-dataset/CreateDataset.tsx b/src/sections/create-dataset/CreateDataset.tsx index 5fd3db78a..986165570 100644 --- a/src/sections/create-dataset/CreateDataset.tsx +++ b/src/sections/create-dataset/CreateDataset.tsx @@ -128,6 +128,7 @@ export function CreateDataset({ datasetRepository={datasetRepository} metadataBlockInfoRepository={metadataBlockInfoRepository} datasetTemplate={selectedTemplate ?? undefined} + key={selectedTemplate ? selectedTemplate.id : 'no-template-selected'} // We use the template id as key to force remounting the form when the template changes /> diff --git a/src/sections/shared/form/DatasetMetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/index.tsx index a0638b658..c426568ea 100644 --- a/src/sections/shared/form/DatasetMetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/index.tsx @@ -8,6 +8,7 @@ import { MetadataFormSkeleton } from './MetadataForm/MetadataFormSkeleton' import { MetadataForm } from './MetadataForm' import { DatasetMetadataBlocks } from '../../../../dataset/domain/models/Dataset' import { Alert } from '@iqss/dataverse-design-system' +import { DatasetTemplate } from '@/dataset/domain/models/DatasetTemplate' type DatasetMetadataFormProps = | { @@ -18,6 +19,7 @@ type DatasetMetadataFormProps = metadataBlockInfoRepository: MetadataBlockInfoRepository datasetMetadaBlocksCurrentValues?: never datasetInternalVersionNumber?: never + datasetTemplate?: DatasetTemplate } | { mode: 'edit' @@ -27,6 +29,7 @@ type DatasetMetadataFormProps = metadataBlockInfoRepository: MetadataBlockInfoRepository datasetMetadaBlocksCurrentValues: DatasetMetadataBlocks datasetInternalVersionNumber: number + datasetTemplate?: never } export type DatasetMetadataFormMode = 'create' | 'edit' @@ -38,7 +41,8 @@ export const DatasetMetadataForm = ({ datasetPersistentID, metadataBlockInfoRepository, datasetMetadaBlocksCurrentValues, - datasetInternalVersionNumber + datasetInternalVersionNumber, + datasetTemplate }: DatasetMetadataFormProps) => { const { setIsLoading } = useLoading() const onEditMode = mode === 'edit' @@ -69,6 +73,15 @@ export const DatasetMetadataForm = ({ ) }, [datasetMetadaBlocksCurrentValues]) + // Dataset Template metadata blocks values properties with dots replaced by slashes to match the metadata blocks info + const normalizedDatasetTemplateMetadataBlocksValues = useMemo(() => { + if (!datasetTemplate) return undefined + + return MetadataFieldsHelper.replaceDatasetMetadataBlocksCurrentValuesDotKeysWithSlash( + datasetTemplate.datasetMetadataBlocks + ) + }, [datasetTemplate]) + // If we are in edit mode, we need to add the values to the metadata blocks info const normalizedMetadataBlocksInfoWithValues = useMemo(() => { if (normalizedMetadataBlocksInfo.length === 0 || !normalizedDatasetMetadaBlocksCurrentValues) { @@ -83,12 +96,48 @@ export const DatasetMetadataForm = ({ : null }, [normalizedMetadataBlocksInfo, normalizedDatasetMetadaBlocksCurrentValues, onEditMode]) + // If we are in create mode and have a dataset template, add template values into the metadata blocks info + const normalizedMetadataBlocksInfoWithTemplateValues = useMemo(() => { + if ( + normalizedMetadataBlocksInfo.length === 0 || + !normalizedDatasetTemplateMetadataBlocksValues + ) { + return null + } + return !onEditMode + ? MetadataFieldsHelper.addFieldValuesToMetadataBlocksInfo( + normalizedMetadataBlocksInfo, + normalizedDatasetTemplateMetadataBlocksValues + ) + : null + }, [normalizedMetadataBlocksInfo, normalizedDatasetTemplateMetadataBlocksValues, onEditMode]) + // Set the form default values object based on the metadata blocks info const formDefaultValues = useMemo(() => { - return onEditMode && normalizedMetadataBlocksInfoWithValues !== null - ? MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfoWithValues) - : MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfo) - }, [normalizedMetadataBlocksInfo, normalizedMetadataBlocksInfoWithValues, onEditMode]) + if (onEditMode) { + if (normalizedMetadataBlocksInfoWithValues !== null) { + // console.log('Edit mode: using current values to set default values') + return MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfoWithValues) + } + // console.log('Edit mode: no current values, using metadata blocks info to set default values') + return MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfo) + } else { + if (datasetTemplate && normalizedMetadataBlocksInfoWithTemplateValues !== null) { + // console.log('Using template to set default values') + return MetadataFieldsHelper.getFormDefaultValues( + normalizedMetadataBlocksInfoWithTemplateValues + ) + } + // console.log('Create mode: using metadata blocks info to set default values') + return MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfo) + } + }, [ + normalizedMetadataBlocksInfo, + normalizedMetadataBlocksInfoWithValues, + normalizedMetadataBlocksInfoWithTemplateValues, + datasetTemplate, + onEditMode + ]) useEffect(() => { setIsLoading(isLoadingMetadataBlocksInfo) From d6d5dc7e09a26cc72692f20a5826579c611b72ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 4 Sep 2025 12:46:50 -0300 Subject: [PATCH 07/24] feat: implement custom hook to prefill fields with user data in create mode --- .../MetadataFieldsHelper.ts | 2 +- .../MetadataForm/index.tsx | 25 +----- .../usePrefillFieldsWithUserData.ts | 82 +++++++++++++++++++ 3 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index d9f6d6251..326d50b88 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -29,7 +29,7 @@ type PrimitiveMultipleFormValue = { value: string }[] type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[] -type ComposedSingleFieldValue = Record +export type ComposedSingleFieldValue = Record export class MetadataFieldsHelper { public static replaceMetadataBlocksInfoDotNamesKeysWithSlash( diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx index f69f2ef28..abe41f548 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react' +import { useMemo, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { FieldErrors, FormProvider, useForm } from 'react-hook-form' @@ -13,7 +13,7 @@ import { MetadataBlockFormFields } from './MetadataBlockFormFields' import { RequiredFieldText } from '../../RequiredFieldText/RequiredFieldText' import { RouteWithParams } from '@/sections/Route.enum' import { SeparationLine } from '@/sections/shared/layout/SeparationLine/SeparationLine' -import { DateHelper } from '@/shared/helpers/DateHelper' +import { usePrefillFieldsWithUserData } from './usePrefillFieldsWithUserData' import styles from './index.module.scss' interface FormProps { @@ -56,25 +56,8 @@ export const MetadataForm = ({ datasetPersistentID, datasetInternalVersionNumber ) - - useEffect(() => { - // Only on create mode, lets prefill specific fields with user data - if (mode === 'create' && user) { - const displayName = `${user.lastName}, ${user.firstName}` - setValue('citation.author.0.authorName', displayName) - setValue('citation.datasetContact.0.datasetContactName', displayName) - setValue('citation.datasetContact.0.datasetContactEmail', user.email, { - shouldValidate: true - }) - setValue('citation.depositor', displayName) - setValue('citation.dateOfDeposit', DateHelper.toISO8601Format(new Date())) - - if (user.affiliation) { - setValue('citation.datasetContact.0.datasetContactAffiliation', user.affiliation) - setValue('citation.author.0.authorAffiliation', user.affiliation) - } - } - }, [setValue, user, mode]) + // Prefill specific fields with user data when in create mode + usePrefillFieldsWithUserData({ mode, user, formDefaultValues, setValue }) const handleCancel = () => { navigate(RouteWithParams.COLLECTIONS(collectionId)) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts new file mode 100644 index 000000000..6530fcb33 --- /dev/null +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts @@ -0,0 +1,82 @@ +import { useEffect, useRef } from 'react' +import { type UseFormSetValue } from 'react-hook-form' +import { type User } from '@/users/domain/models/User' +import { + type ComposedSingleFieldValue, + type DatasetMetadataFormValues +} from '../MetadataFieldsHelper' +import { type DatasetMetadataFormMode } from '..' +import { DateHelper } from '@/shared/helpers/DateHelper' + +interface UsePrefillFieldsWithUserDataProps { + mode: DatasetMetadataFormMode + user: User | null + formDefaultValues: DatasetMetadataFormValues + setValue: UseFormSetValue +} + +/** + * This hook is used to prefill specific fields with user data when in create mode. + * It checks if the user is available and if the mode is 'create'. + * It also ensures that it does not overwrite any existing values in the formDefaultValues that might come from a template. + */ + +export const usePrefillFieldsWithUserData = ({ + mode, + user, + formDefaultValues, + setValue +}: UsePrefillFieldsWithUserDataProps) => { + const didPrefillRef = useRef(false) + useEffect(() => { + if (didPrefillRef.current) return + if (mode !== 'create' || !user) return + + const displayName = `${user.lastName}, ${user.firstName}` + + const authorName0 = (formDefaultValues?.citation?.author as ComposedSingleFieldValue[])?.[0] + .authorName + const datasetContact0 = ( + formDefaultValues?.citation?.datasetContact as ComposedSingleFieldValue[] + )?.[0].datasetContactName + const datasetContactEmail0 = ( + formDefaultValues?.citation?.datasetContact as ComposedSingleFieldValue[] + )?.[0].datasetContactEmail + const depositor = formDefaultValues?.citation?.depositor as string + const dateOfDeposit = formDefaultValues?.citation?.dateOfDeposit as string + const datasetContactAffiliation = ( + formDefaultValues?.citation?.datasetContact as ComposedSingleFieldValue[] + )?.[0].datasetContactAffiliation + const authorAffiliation0 = ( + formDefaultValues?.citation?.author as ComposedSingleFieldValue[] + )?.[0].authorAffiliation + + if (!authorName0) { + setValue('citation.author.0.authorName', displayName) + } + if (!datasetContact0) { + setValue('citation.datasetContact.0.datasetContactName', displayName) + } + if (!datasetContactEmail0) { + setValue('citation.datasetContact.0.datasetContactEmail', user.email, { + shouldValidate: true + }) + } + if (!depositor) { + setValue('citation.depositor', displayName) + } + if (!dateOfDeposit) { + setValue('citation.dateOfDeposit', DateHelper.toISO8601Format(new Date())) + } + + if (user.affiliation) { + if (!datasetContactAffiliation) { + setValue('citation.datasetContact.0.datasetContactAffiliation', user.affiliation) + } + if (!authorAffiliation0) { + setValue('citation.author.0.authorAffiliation', user.affiliation) + } + } + didPrefillRef.current = true + }, [setValue, user, mode, formDefaultValues]) +} From 3a3d56403a27306e0d96c2bba13aced6aed8087a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 4 Sep 2025 12:51:39 -0300 Subject: [PATCH 08/24] refactor: avoid disabling button if user did not change the fields --- .../form/DatasetMetadataForm/MetadataForm/index.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx index abe41f548..2896b15ba 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { useRef } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { FieldErrors, FormProvider, useForm } from 'react-hook-form' @@ -46,7 +46,7 @@ export const MetadataForm = ({ const onEditMode = mode === 'edit' const form = useForm({ mode: 'onChange', defaultValues: formDefaultValues }) - const { setValue, formState } = form + const { setValue } = form const { submissionStatus, submitError, submitForm } = useSubmitDataset( mode, @@ -56,7 +56,7 @@ export const MetadataForm = ({ datasetPersistentID, datasetInternalVersionNumber ) - // Prefill specific fields with user data when in create mode + usePrefillFieldsWithUserData({ mode, user, formDefaultValues, setValue }) const handleCancel = () => { @@ -100,9 +100,7 @@ export const MetadataForm = ({ } } - const disableSubmitButton = useMemo(() => { - return submissionStatus === SubmissionStatus.IsSubmitting || !formState.isDirty - }, [submissionStatus, formState.isDirty]) + const disableSubmitButton = submissionStatus === SubmissionStatus.IsSubmitting const preventEnterSubmit = (e: React.KeyboardEvent) => { // When pressing Enter, only submit the form if the user is focused on the submit button itself From 3938bc846852cdeda1151b9858d7da8c9312e8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 4 Sep 2025 16:40:51 -0300 Subject: [PATCH 09/24] fix: avoid prefilling a subfield value with user data if siblings have value --- .../MetadataForm/usePrefillFieldsWithUserData.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts index 6530fcb33..c0118ab42 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts @@ -51,17 +51,13 @@ export const usePrefillFieldsWithUserData = ({ formDefaultValues?.citation?.author as ComposedSingleFieldValue[] )?.[0].authorAffiliation - if (!authorName0) { - setValue('citation.author.0.authorName', displayName) - } - if (!datasetContact0) { + if (!datasetContact0 && !datasetContactEmail0) { setValue('citation.datasetContact.0.datasetContactName', displayName) - } - if (!datasetContactEmail0) { setValue('citation.datasetContact.0.datasetContactEmail', user.email, { shouldValidate: true }) } + if (!depositor) { setValue('citation.depositor', displayName) } @@ -69,11 +65,13 @@ export const usePrefillFieldsWithUserData = ({ setValue('citation.dateOfDeposit', DateHelper.toISO8601Format(new Date())) } + if (!authorName0 && !authorAffiliation0) { + setValue('citation.author.0.authorName', displayName) + } + if (user.affiliation) { - if (!datasetContactAffiliation) { + if (!authorName0 && !datasetContactAffiliation && !authorAffiliation0) { setValue('citation.datasetContact.0.datasetContactAffiliation', user.affiliation) - } - if (!authorAffiliation0) { setValue('citation.author.0.authorAffiliation', user.affiliation) } } From 8a575611caf0e22fee23547b8085fa2c81d5ab55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 4 Sep 2025 16:43:05 -0300 Subject: [PATCH 10/24] refactor: simplify defining metadata blocks info and default values in edit and create mode --- .../MetadataFieldsHelper.ts | 172 +++++++++++++++++- .../shared/form/DatasetMetadataForm/index.tsx | 128 ++++--------- 2 files changed, 208 insertions(+), 92 deletions(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index 326d50b88..faec040fd 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -31,6 +31,9 @@ type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[] export type ComposedSingleFieldValue = Record +// TODO:ME - Probably we dont need structuredClone if some places +// TODO:ME - Clean comments made in spanish + export class MetadataFieldsHelper { public static replaceMetadataBlocksInfoDotNamesKeysWithSlash( metadataBlocks: MetadataBlockInfo[] @@ -58,7 +61,7 @@ export class MetadataFieldsHelper { } } - public static replaceDatasetMetadataBlocksCurrentValuesDotKeysWithSlash( + public static replaceDatasetMetadataBlocksDotKeysWithSlash( datasetMetadataBlocks: DatasetMetadataBlocks ): DatasetMetadataBlocks { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -465,4 +468,171 @@ export class MetadataFieldsHelper { ): value is ComposedSingleFieldValue[] => { return Array.isArray(value) && value.every((v) => typeof v === 'object') } + + /** + * To define the metadata blocks info that will be used to render the form. + * In create mode, if a template is provided, it adds the fields from the template to the metadata blocks info for create. + * It also adds the values from the template to the metadata blocks info for create. + * In edit mode, it adds the current values to the metadata blocks info for edit. + * Finally, it orders the fields by display order. + */ + public static defineMetadataBlockInfo( + mode: 'create' | 'edit', + metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[], + metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[], + datasetMetadaBlocksCurrentValues: DatasetMetadataBlocks | undefined, + templateMetadataBlocks: DatasetMetadataBlocks | undefined + ): MetadataBlockInfo[] { + // Replace field names with dots to slashes, to avoid issues with the form library react-hook-form + const normalizedMetadataBlocksInfoForDisplayOnCreate = + this.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfoForDisplayOnCreate) + + const normalizedMetadataBlocksInfoForDisplayOnEdit = + this.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfoForDisplayOnEdit) + + // CREATE MODE + if (mode === 'create') { + // If we have no template, we just return the metadata blocks info for create with normalized field names + if (!templateMetadataBlocks) { + return normalizedMetadataBlocksInfoForDisplayOnCreate + } + + // 1) Normalize dataset template fields + const normalizedDatasetTemplateMetadataBlocksValues = + this.replaceDatasetMetadataBlocksDotKeysWithSlash(templateMetadataBlocks) + + // 2) Add missing fields from the template to the metadata blocks info for create + const metadataBlocksInfoWithAddedFieldsFromTemplate = + this.addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate( + normalizedMetadataBlocksInfoForDisplayOnCreate, + normalizedMetadataBlocksInfoForDisplayOnEdit, + normalizedDatasetTemplateMetadataBlocksValues + ) + // 3) Add the values from the template to the metadata blocks info for create + const metadataBlocksInfoWithValuesFromTemplate = this.addFieldValuesToMetadataBlocksInfo( + metadataBlocksInfoWithAddedFieldsFromTemplate, + normalizedDatasetTemplateMetadataBlocksValues + ) + + // 5) Order fields by display order + const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder( + metadataBlocksInfoWithValuesFromTemplate + ) + + return metadataBlocksInfoOrdered + } else { + // EDIT MODE + const datasetCurrentValues = datasetMetadaBlocksCurrentValues as DatasetMetadataBlocks // In edit mode we always have current values + + // 1) Normalize dataset current values + const normalizedDatasetMetadaBlocksCurrentValues = + this.replaceDatasetMetadataBlocksDotKeysWithSlash(datasetCurrentValues) + + // 2) Add current values to the metadata blocks info for edit + const metadataBlocksInfoWithCurrentValues = this.addFieldValuesToMetadataBlocksInfo( + normalizedMetadataBlocksInfoForDisplayOnEdit, + normalizedDatasetMetadaBlocksCurrentValues + ) + + // 3) Order fields by display order + const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder( + metadataBlocksInfoWithCurrentValues + ) + + return metadataBlocksInfoOrdered + } + } + + public static addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate( + metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[], + metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[], + templateFields: DatasetMetadataBlocks | undefined + ): MetadataBlockInfo[] { + if (!templateFields || templateFields.length === 0) { + return metadataBlocksInfoForDisplayOnCreate + } + + // Trabajamos sobre una copia + const createCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfoForDisplayOnCreate) + + // Mapas ΓΊtiles + const createMap = createCopy.reduce>((acc, block) => { + acc[block.name] = block + return acc + }, {}) + + const editMap = metadataBlocksInfoForDisplayOnEdit.reduce>( + (acc, block) => { + acc[block.name] = block + return acc + }, + {} + ) + + // Recorremos los bloques del template (el template solo trae valores) + for (const tBlock of templateFields) { + const blockName = tBlock.name + const editBlock = editMap[blockName] + if (!editBlock) { + // No sabemos cΓ³mo luce este bloque en "edit", no podemos copiar su forma + continue + } + + // Aseguramos que el bloque exista en el array de "create" + let createBlock = createMap[blockName] + if (!createBlock) { + createBlock = { + id: editBlock.id, + name: editBlock.name, + displayName: editBlock.displayName, + metadataFields: {}, + displayOnCreate: editBlock.displayOnCreate + } + createMap[blockName] = createBlock + createCopy.push(createBlock) + } + + const createFields = createBlock.metadataFields + const editFields = editBlock.metadataFields + + // Por cada field que el template trae con valor, si no existe en "create", lo copiamos desde "edit" + const templateBlockFields = tBlock.fields ?? {} + for (const fieldName of Object.keys(templateBlockFields)) { + if (createFields[fieldName]) continue + + const fieldFromEdit = editFields[fieldName] + if (!fieldFromEdit) { + // El field no existe ni en "edit": no hay forma de conocer su forma; lo saltamos + continue + } + + const clonedField = structuredClone(fieldFromEdit) + + createFields[fieldName] = clonedField + } + } + + return createCopy + } + + private static orderFieldsByDisplayOrder( + metadataBlocksInfo: MetadataBlockInfo[] + ): MetadataBlockInfo[] { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const metadataBlocksInfoCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfo) + + for (const block of metadataBlocksInfoCopy) { + if (block.metadataFields) { + const fieldsArray = Object.values(block.metadataFields) + fieldsArray.sort((a, b) => a.displayOrder - b.displayOrder) + + const orderedFields: Record = {} + for (const field of fieldsArray) { + orderedFields[field.name] = field + } + block.metadataFields = orderedFields + } + } + return metadataBlocksInfoCopy + } } diff --git a/src/sections/shared/form/DatasetMetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/index.tsx index c426568ea..cf017f0d2 100644 --- a/src/sections/shared/form/DatasetMetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { useLoading } from '../../../loading/LoadingContext' import { useGetMetadataBlocksInfo } from './useGetMetadataBlocksInfo' import { DatasetRepository } from '../../../../dataset/domain/repositories/DatasetRepository' @@ -34,6 +34,8 @@ type DatasetMetadataFormProps = export type DatasetMetadataFormMode = 'create' | 'edit' +// TODO:ME - Check we render the form with defaultValues only once. + export const DatasetMetadataForm = ({ mode, collectionId, @@ -45,122 +47,66 @@ export const DatasetMetadataForm = ({ datasetTemplate }: DatasetMetadataFormProps) => { const { setIsLoading } = useLoading() - const onEditMode = mode === 'edit' const { - metadataBlocksInfo, - isLoading: isLoadingMetadataBlocksInfo, - error: errorLoadingMetadataBlocksInfo + metadataBlocksInfo: metadataBlocksInfoForDisplayOnCreate, + isLoading: isLoadingMetadataBlocksInfoForDisplayOnCreate, + error: errorLoadingMetadataBlocksInfoForDisplayOnCreate } = useGetMetadataBlocksInfo({ - mode, + mode: 'create', collectionId, metadataBlockInfoRepository }) - // Metadata blocks info with field names that have dots replaced by slashes - const normalizedMetadataBlocksInfo = useMemo(() => { - if (metadataBlocksInfo.length === 0) return [] - - return MetadataFieldsHelper.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfo) - }, [metadataBlocksInfo]) - - // Dataset metadata blocks current values properties with dots replaced by slashes to match the metadata blocks info - const normalizedDatasetMetadaBlocksCurrentValues = useMemo(() => { - if (!datasetMetadaBlocksCurrentValues) return undefined - - return MetadataFieldsHelper.replaceDatasetMetadataBlocksCurrentValuesDotKeysWithSlash( - datasetMetadaBlocksCurrentValues - ) - }, [datasetMetadaBlocksCurrentValues]) - - // Dataset Template metadata blocks values properties with dots replaced by slashes to match the metadata blocks info - const normalizedDatasetTemplateMetadataBlocksValues = useMemo(() => { - if (!datasetTemplate) return undefined - - return MetadataFieldsHelper.replaceDatasetMetadataBlocksCurrentValuesDotKeysWithSlash( - datasetTemplate.datasetMetadataBlocks - ) - }, [datasetTemplate]) - - // If we are in edit mode, we need to add the values to the metadata blocks info - const normalizedMetadataBlocksInfoWithValues = useMemo(() => { - if (normalizedMetadataBlocksInfo.length === 0 || !normalizedDatasetMetadaBlocksCurrentValues) { - return null - } - - return onEditMode - ? MetadataFieldsHelper.addFieldValuesToMetadataBlocksInfo( - normalizedMetadataBlocksInfo, - normalizedDatasetMetadaBlocksCurrentValues - ) - : null - }, [normalizedMetadataBlocksInfo, normalizedDatasetMetadaBlocksCurrentValues, onEditMode]) + const { + metadataBlocksInfo: metadataBlocksInfoForDisplayOnEdit, + isLoading: isLoadingMetadataBlocksInfoForDisplayOnEdit, + error: errorLoadingMetadataBlocksInfoForDisplayOnEdit + } = useGetMetadataBlocksInfo({ + mode: 'edit', + collectionId, + metadataBlockInfoRepository + }) - // If we are in create mode and have a dataset template, add template values into the metadata blocks info - const normalizedMetadataBlocksInfoWithTemplateValues = useMemo(() => { - if ( - normalizedMetadataBlocksInfo.length === 0 || - !normalizedDatasetTemplateMetadataBlocksValues - ) { - return null - } - return !onEditMode - ? MetadataFieldsHelper.addFieldValuesToMetadataBlocksInfo( - normalizedMetadataBlocksInfo, - normalizedDatasetTemplateMetadataBlocksValues - ) - : null - }, [normalizedMetadataBlocksInfo, normalizedDatasetTemplateMetadataBlocksValues, onEditMode]) + const isLoadingData = + isLoadingMetadataBlocksInfoForDisplayOnCreate || isLoadingMetadataBlocksInfoForDisplayOnEdit - // Set the form default values object based on the metadata blocks info - const formDefaultValues = useMemo(() => { - if (onEditMode) { - if (normalizedMetadataBlocksInfoWithValues !== null) { - // console.log('Edit mode: using current values to set default values') - return MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfoWithValues) - } - // console.log('Edit mode: no current values, using metadata blocks info to set default values') - return MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfo) - } else { - if (datasetTemplate && normalizedMetadataBlocksInfoWithTemplateValues !== null) { - // console.log('Using template to set default values') - return MetadataFieldsHelper.getFormDefaultValues( - normalizedMetadataBlocksInfoWithTemplateValues - ) - } - // console.log('Create mode: using metadata blocks info to set default values') - return MetadataFieldsHelper.getFormDefaultValues(normalizedMetadataBlocksInfo) - } - }, [ - normalizedMetadataBlocksInfo, - normalizedMetadataBlocksInfoWithValues, - normalizedMetadataBlocksInfoWithTemplateValues, - datasetTemplate, - onEditMode - ]) + const errorLoadingData = + errorLoadingMetadataBlocksInfoForDisplayOnCreate || + errorLoadingMetadataBlocksInfoForDisplayOnEdit useEffect(() => { - setIsLoading(isLoadingMetadataBlocksInfo) - }, [isLoadingMetadataBlocksInfo, setIsLoading]) + setIsLoading(isLoadingData) + }, [isLoadingData, setIsLoading]) - if (isLoadingMetadataBlocksInfo || !formDefaultValues) { + if (isLoadingData) { return } - if (errorLoadingMetadataBlocksInfo) { + if (errorLoadingData) { return ( - {errorLoadingMetadataBlocksInfo} + {errorLoadingData} ) } + const metadataBlocksInfo = MetadataFieldsHelper.defineMetadataBlockInfo( + mode, + metadataBlocksInfoForDisplayOnCreate, + metadataBlocksInfoForDisplayOnEdit, + datasetMetadaBlocksCurrentValues, + datasetTemplate?.datasetMetadataBlocks + ) + + const formDefaultValues = MetadataFieldsHelper.getFormDefaultValues(metadataBlocksInfo) + return ( Date: Fri, 5 Sep 2025 10:35:07 -0300 Subject: [PATCH 11/24] fix: tweaks --- src/dataset/domain/models/Dataset.ts | 4 ++-- .../MetadataFieldsHelper.ts | 23 +++++++------------ .../domain/models/DatasetTemplateMother.ts | 2 +- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/dataset/domain/models/Dataset.ts b/src/dataset/domain/models/Dataset.ts index 1edb088fc..2ee2c67ed 100644 --- a/src/dataset/domain/models/Dataset.ts +++ b/src/dataset/domain/models/Dataset.ts @@ -104,8 +104,8 @@ export interface CitationMetadataBlock extends DatasetMetadataBlock { } interface OtherId extends DatasetMetadataSubField { - otherIdAgency: string - otherIdValue: string + otherIdAgency?: string + otherIdValue?: string } export interface Author extends DatasetMetadataSubField { diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index faec040fd..edc0ca0e2 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -31,9 +31,6 @@ type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[] export type ComposedSingleFieldValue = Record -// TODO:ME - Probably we dont need structuredClone if some places -// TODO:ME - Clean comments made in spanish - export class MetadataFieldsHelper { public static replaceMetadataBlocksInfoDotNamesKeysWithSlash( metadataBlocks: MetadataBlockInfo[] @@ -64,11 +61,9 @@ export class MetadataFieldsHelper { public static replaceDatasetMetadataBlocksDotKeysWithSlash( datasetMetadataBlocks: DatasetMetadataBlocks ): DatasetMetadataBlocks { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const datasetMetadataBlocksCopy: DatasetMetadataBlocks = structuredClone(datasetMetadataBlocks) const dataWithoutKeysWithDots: DatasetMetadataBlocks = [] as unknown as DatasetMetadataBlocks - for (const block of datasetMetadataBlocksCopy) { + for (const block of datasetMetadataBlocks) { const newBlockFields: DatasetMetadataFields = this.datasetMetadataBlocksCurrentValuesDotReplacer(block.fields) @@ -471,9 +466,9 @@ export class MetadataFieldsHelper { /** * To define the metadata blocks info that will be used to render the form. - * In create mode, if a template is provided, it adds the fields from the template to the metadata blocks info for create. - * It also adds the values from the template to the metadata blocks info for create. - * In edit mode, it adds the current values to the metadata blocks info for edit. + * In create mode, if a template is provided, it adds the fields and values from the template to the metadata blocks info. + * In edit mode, it adds the current dataset values to the metadata blocks info. + * Normalizes field names by replacing dots with slashes to avoid issues with react-hook-form. (e.g. coverage.Spectral.MinimumWavelength -> coverage/Spectral/MinimumWavelength) * Finally, it orders the fields by display order. */ public static defineMetadataBlockInfo( @@ -552,10 +547,8 @@ export class MetadataFieldsHelper { return metadataBlocksInfoForDisplayOnCreate } - // Trabajamos sobre una copia const createCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfoForDisplayOnCreate) - // Mapas ΓΊtiles const createMap = createCopy.reduce>((acc, block) => { acc[block.name] = block return acc @@ -569,16 +562,15 @@ export class MetadataFieldsHelper { {} ) - // Recorremos los bloques del template (el template solo trae valores) for (const tBlock of templateFields) { const blockName = tBlock.name const editBlock = editMap[blockName] if (!editBlock) { - // No sabemos cΓ³mo luce este bloque en "edit", no podemos copiar su forma + // We don't know how this block looks in "edit", we can't copy its shape. So we skip it. continue } - // Aseguramos que el bloque exista en el array de "create" + // We ensure the block exists in the "create" array let createBlock = createMap[blockName] if (!createBlock) { createBlock = { @@ -596,13 +588,14 @@ export class MetadataFieldsHelper { const editFields = editBlock.metadataFields // Por cada field que el template trae con valor, si no existe en "create", lo copiamos desde "edit" + // For each field that the template brings with value, if it doesn't exist in "create", we copy it from "edit" const templateBlockFields = tBlock.fields ?? {} for (const fieldName of Object.keys(templateBlockFields)) { if (createFields[fieldName]) continue const fieldFromEdit = editFields[fieldName] if (!fieldFromEdit) { - // El field no existe ni en "edit": no hay forma de conocer su forma; lo saltamos + // The field doesn't exist in "edit" either: there's no way to know its shape; we skip it continue } diff --git a/tests/component/dataset/domain/models/DatasetTemplateMother.ts b/tests/component/dataset/domain/models/DatasetTemplateMother.ts index 02b938bec..98bab1f3b 100644 --- a/tests/component/dataset/domain/models/DatasetTemplateMother.ts +++ b/tests/component/dataset/domain/models/DatasetTemplateMother.ts @@ -12,7 +12,7 @@ export class DatasetTemplateMother { collectionAlias: faker.lorem.word({ length: { min: 3, max: 15 } }), createTime: 'Tue Sep 02 13:13:47 UTC 2025', createDate: 'Sep 2, 2025', - datasetFields: {}, + datasetMetadataBlocks: [] as unknown as DatasetTemplate['datasetMetadataBlocks'], isDefault: faker.datatype.boolean(), usageCount: faker.datatype.number({ min: 0, max: 100 }), instructions: [], From ca1f6eb96c3dc3974ca182d62d5b28b4c9b248c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 5 Sep 2025 12:04:08 -0300 Subject: [PATCH 12/24] fix: replace dots in key names also and dont push the block if template fields are empty --- .../MetadataFieldsHelper.ts | 20 +- .../MetadataFieldsHelper.spec.ts | 254 +++++++++++------- 2 files changed, 176 insertions(+), 98 deletions(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index edc0ca0e2..8527c6d1c 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -49,6 +49,12 @@ export class MetadataFieldsHelper { private static metadataBlocksInfoDotReplacer(metadataFields: Record) { for (const key in metadataFields) { const field = metadataFields[key] + const fieldReplacedKey = this.replaceDotWithSlash(key) + if (fieldReplacedKey !== key) { + // Change the key in the object only if it has changed (i.e., it had a dot) + metadataFields[fieldReplacedKey] = field + delete metadataFields[key] + } if (field.name.includes('.')) { field.name = this.replaceDotWithSlash(field.name) } @@ -541,9 +547,9 @@ export class MetadataFieldsHelper { public static addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate( metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[], metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[], - templateFields: DatasetMetadataBlocks | undefined + templateBlocks: DatasetMetadataBlocks | undefined ): MetadataBlockInfo[] { - if (!templateFields || templateFields.length === 0) { + if (!templateBlocks || templateBlocks.length === 0) { return metadataBlocksInfoForDisplayOnCreate } @@ -562,9 +568,15 @@ export class MetadataFieldsHelper { {} ) - for (const tBlock of templateFields) { + for (const tBlock of templateBlocks) { const blockName = tBlock.name const editBlock = editMap[blockName] + + // Could be the case that the template block is returned from the API but it has no fields, so we skip it. + const templateBlockHasFields: boolean = Object.keys(tBlock.fields ?? {}).length > 0 + + if (!templateBlockHasFields) continue + if (!editBlock) { // We don't know how this block looks in "edit", we can't copy its shape. So we skip it. continue @@ -572,6 +584,7 @@ export class MetadataFieldsHelper { // We ensure the block exists in the "create" array let createBlock = createMap[blockName] + if (!createBlock) { createBlock = { id: editBlock.id, @@ -587,7 +600,6 @@ export class MetadataFieldsHelper { const createFields = createBlock.metadataFields const editFields = editBlock.metadataFields - // Por cada field que el template trae con valor, si no existe en "create", lo copiamos desde "edit" // For each field that the template brings with value, if it doesn't exist in "create", we copy it from "edit" const templateBlockFields = tBlock.fields ?? {} for (const fieldName of Object.keys(templateBlockFields)) { diff --git a/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts b/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts index f36564fd7..91103062e 100644 --- a/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts +++ b/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts @@ -30,7 +30,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, controlledVocabularyNotMultiple: { name: 'controlledVocabularyNotMultiple', @@ -46,7 +47,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 10, typeClass: 'controlledVocabulary', - displayOnCreate: true + displayOnCreate: true, + isAdvancedSearchFieldType: false }, controlledVocabularyMultiple: { name: 'controlledVocabularyMultiple', @@ -62,7 +64,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 10, typeClass: 'controlledVocabulary', - displayOnCreate: true + displayOnCreate: true, + isAdvancedSearchFieldType: false }, 'primitive.text.not.multiple': { name: 'primitive.text.not.multiple', @@ -77,7 +80,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, 'primitive.text.multiple': { name: 'primitive.text.multiple', @@ -92,7 +96,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, 'primitive.textbox.not.multiple': { name: 'primitive.textbox.not.multiple', @@ -107,7 +112,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, 'primitive.textbox.multiple': { name: 'primitive.textbox.multiple', @@ -122,7 +128,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, 'primitive.float.not.multiple': { name: 'primitive.float.not.multiple', @@ -137,7 +144,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 22, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, 'primitive.float.multiple': { name: 'primitive.float.multiple', @@ -152,7 +160,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 22, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, 'primitive.int.not.multiple': { name: 'primitive.int.not.multiple', @@ -167,7 +176,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 18, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, 'primitive.int.multiple': { name: 'primitive.int.multiple', @@ -182,7 +192,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 18, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, 'primitive.date.not.multiple': { name: 'primitive.date.not.multiple', @@ -198,7 +209,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 42, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, 'primitive.date.multiple': { name: 'primitive.date.multiple', @@ -214,7 +226,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 42, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, 'composed.field.multiple': { name: 'composed.field.multiple', @@ -230,6 +243,7 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOnCreate: true, displayOrder: 12, + isAdvancedSearchFieldType: false, childMetadataFields: { 'subfield.1': { name: 'subfield.1', @@ -244,7 +258,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 13 + displayOrder: 13, + isAdvancedSearchFieldType: false }, 'subfield.2': { name: 'subfield.2', @@ -259,7 +274,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 14 + displayOrder: 14, + isAdvancedSearchFieldType: false }, someNestedKeyWithoutDot: { name: 'someNestedKeyWithoutDot', @@ -274,7 +290,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, controlledVocabularyNotMultiple: { name: 'controlledVocabularyNotMultiple', @@ -290,7 +307,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 10, typeClass: 'controlledVocabulary', - displayOnCreate: true + displayOnCreate: true, + isAdvancedSearchFieldType: false } } }, @@ -308,6 +326,7 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOnCreate: true, displayOrder: 12, + isAdvancedSearchFieldType: false, childMetadataFields: { 'subfield.1': { name: 'subfield.1', @@ -322,7 +341,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 13 + displayOrder: 13, + isAdvancedSearchFieldType: false }, 'subfield.2': { name: 'subfield.2', @@ -337,7 +357,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 14 + displayOrder: 14, + isAdvancedSearchFieldType: false }, someNestedKeyWithoutDot: { name: 'someNestedKeyWithoutDot', @@ -352,7 +373,8 @@ const metadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false } } } @@ -380,7 +402,8 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, controlledVocabularyNotMultiple: { name: 'controlledVocabularyNotMultiple', @@ -396,7 +419,8 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 10, typeClass: 'controlledVocabulary', - displayOnCreate: true + displayOnCreate: true, + isAdvancedSearchFieldType: false }, controlledVocabularyMultiple: { name: 'controlledVocabularyMultiple', @@ -412,9 +436,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 10, typeClass: 'controlledVocabulary', - displayOnCreate: true + displayOnCreate: true, + isAdvancedSearchFieldType: false }, - 'primitive.text.not.multiple': { + 'primitive/text/not/multiple': { name: 'primitive/text/not/multiple', displayName: 'foo', title: 'foo', @@ -427,9 +452,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, - 'primitive.text.multiple': { + 'primitive/text/multiple': { name: 'primitive/text/multiple', displayName: 'foo', title: 'foo', @@ -442,9 +468,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, - 'primitive.textbox.not.multiple': { + 'primitive/textbox/not/multiple': { name: 'primitive/textbox/not/multiple', displayName: 'foo', title: 'foo', @@ -457,9 +484,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, - 'primitive.textbox.multiple': { + 'primitive/textbox/multiple': { name: 'primitive/textbox/multiple', displayName: 'foo', title: 'foo', @@ -472,9 +500,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, - 'primitive.float.not.multiple': { + 'primitive/float/not/multiple': { name: 'primitive/float/not/multiple', displayName: 'foo', title: 'foo', @@ -487,9 +516,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 22, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, - 'primitive.float.multiple': { + 'primitive/float/multiple': { name: 'primitive/float/multiple', displayName: 'foo', title: 'foo', @@ -502,9 +532,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 22, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, - 'primitive.int.not.multiple': { + 'primitive/int/not/multiple': { name: 'primitive/int/not/multiple', displayName: 'foo', title: 'foo', @@ -517,9 +548,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 18, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, - 'primitive.int.multiple': { + 'primitive/int/multiple': { name: 'primitive/int/multiple', displayName: 'foo', title: 'foo', @@ -532,9 +564,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: true, displayOrder: 18, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, - 'primitive.date.not.multiple': { + 'primitive/date/not/multiple': { name: 'primitive/date/not/multiple', displayName: 'foo', title: 'foo', @@ -548,9 +581,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 42, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, - 'primitive.date.multiple': { + 'primitive/date/multiple': { name: 'primitive/date/multiple', displayName: 'foo', title: 'foo', @@ -564,9 +598,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 42, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, - 'composed.field.multiple': { + 'composed/field/multiple': { name: 'composed/field/multiple', displayName: 'Foo', title: 'Foo', @@ -580,8 +615,9 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOnCreate: true, displayOrder: 12, + isAdvancedSearchFieldType: false, childMetadataFields: { - 'subfield.1': { + 'subfield/1': { name: 'subfield/1', displayName: 'bar', title: 'Start', @@ -594,9 +630,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 13 + displayOrder: 13, + isAdvancedSearchFieldType: false }, - 'subfield.2': { + 'subfield/2': { name: 'subfield/2', displayName: 'bar', title: 'End', @@ -609,7 +646,8 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 14 + displayOrder: 14, + isAdvancedSearchFieldType: false }, someNestedKeyWithoutDot: { name: 'someNestedKeyWithoutDot', @@ -624,7 +662,8 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, controlledVocabularyNotMultiple: { name: 'controlledVocabularyNotMultiple', @@ -640,11 +679,12 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOrder: 10, typeClass: 'controlledVocabulary', - displayOnCreate: true + displayOnCreate: true, + isAdvancedSearchFieldType: false } } }, - 'composed.field.not.multiple': { + 'composed/field/not/multiple': { name: 'composed/field/not/multiple', displayName: 'Foo', title: 'Foo', @@ -658,8 +698,9 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ isRequired: false, displayOnCreate: true, displayOrder: 12, + isAdvancedSearchFieldType: false, childMetadataFields: { - 'subfield.1': { + 'subfield/1': { name: 'subfield/1', displayName: 'bar', title: 'Start', @@ -672,9 +713,10 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 13 + displayOrder: 13, + isAdvancedSearchFieldType: false }, - 'subfield.2': { + 'subfield/2': { name: 'subfield/2', displayName: 'bar', title: 'End', @@ -687,7 +729,8 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 14 + displayOrder: 14, + isAdvancedSearchFieldType: false }, someNestedKeyWithoutDot: { name: 'someNestedKeyWithoutDot', @@ -702,7 +745,8 @@ const normalizedMetadataBlocksInfo: MetadataBlockInfo[] = [ displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false } } } @@ -731,7 +775,8 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: true, displayOnCreate: true, displayOrder: 0, - value: 'bar' + value: 'bar', + isAdvancedSearchFieldType: false }, controlledVocabularyNotMultiple: { name: 'controlledVocabularyNotMultiple', @@ -748,7 +793,8 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 10, typeClass: 'controlledVocabulary', displayOnCreate: true, - value: 'Option2' + value: 'Option2', + isAdvancedSearchFieldType: false }, controlledVocabularyMultiple: { name: 'controlledVocabularyMultiple', @@ -765,9 +811,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 10, typeClass: 'controlledVocabulary', displayOnCreate: true, - value: ['Option1'] + value: ['Option1'], + isAdvancedSearchFieldType: false }, - 'primitive.text.not.multiple': { + 'primitive/text/not/multiple': { name: 'primitive/text/not/multiple', displayName: 'foo', title: 'foo', @@ -781,9 +828,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: true, displayOnCreate: true, displayOrder: 0, - value: 'foo' + value: 'foo', + isAdvancedSearchFieldType: false }, - 'primitive.text.multiple': { + 'primitive/text/multiple': { name: 'primitive/text/multiple', displayName: 'foo', title: 'foo', @@ -797,9 +845,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: true, displayOnCreate: true, displayOrder: 0, - value: ['foo', 'bar'] + value: ['foo', 'bar'], + isAdvancedSearchFieldType: false }, - 'primitive.textbox.not.multiple': { + 'primitive/textbox/not/multiple': { name: 'primitive/textbox/not/multiple', displayName: 'foo', title: 'foo', @@ -813,9 +862,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: true, displayOnCreate: true, displayOrder: 0, - value: '' + value: '', + isAdvancedSearchFieldType: false }, - 'primitive.textbox.multiple': { + 'primitive/textbox/multiple': { name: 'primitive/textbox/multiple', displayName: 'foo', title: 'foo', @@ -829,9 +879,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: true, displayOnCreate: true, displayOrder: 0, - value: [] + value: [], + isAdvancedSearchFieldType: false }, - 'primitive.float.not.multiple': { + 'primitive/float/not/multiple': { name: 'primitive/float/not/multiple', displayName: 'foo', title: 'foo', @@ -845,9 +896,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 22, typeClass: 'primitive', displayOnCreate: false, - value: '23.55' + value: '23.55', + isAdvancedSearchFieldType: false }, - 'primitive.float.multiple': { + 'primitive/float/multiple': { name: 'primitive/float/multiple', displayName: 'foo', title: 'foo', @@ -861,9 +913,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 22, typeClass: 'primitive', displayOnCreate: false, - value: ['23.55', '45.55'] + value: ['23.55', '45.55'], + isAdvancedSearchFieldType: false }, - 'primitive.int.not.multiple': { + 'primitive/int/not/multiple': { name: 'primitive/int/not/multiple', displayName: 'foo', title: 'foo', @@ -877,9 +930,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 18, typeClass: 'primitive', displayOnCreate: false, - value: '23' + value: '23', + isAdvancedSearchFieldType: false }, - 'primitive.int.multiple': { + 'primitive/int/multiple': { name: 'primitive/int/multiple', displayName: 'foo', title: 'foo', @@ -893,9 +947,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 18, typeClass: 'primitive', displayOnCreate: false, - value: ['23', '45'] + value: ['23', '45'], + isAdvancedSearchFieldType: false }, - 'primitive.date.not.multiple': { + 'primitive/date/not/multiple': { name: 'primitive/date/not/multiple', displayName: 'foo', title: 'foo', @@ -910,9 +965,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 42, typeClass: 'primitive', displayOnCreate: false, - value: '2022-01-01' + value: '2022-01-01', + isAdvancedSearchFieldType: false }, - 'primitive.date.multiple': { + 'primitive/date/multiple': { name: 'primitive/date/multiple', displayName: 'foo', title: 'foo', @@ -927,9 +983,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayOrder: 42, typeClass: 'primitive', displayOnCreate: false, - value: ['2022-01-01', '2022-12-31'] + value: ['2022-01-01', '2022-12-31'], + isAdvancedSearchFieldType: false }, - 'composed.field.multiple': { + 'composed/field/multiple': { name: 'composed/field/multiple', displayName: 'Foo', title: 'Foo', @@ -943,8 +1000,9 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: false, displayOnCreate: true, displayOrder: 12, + isAdvancedSearchFieldType: false, childMetadataFields: { - 'subfield.1': { + 'subfield/1': { name: 'subfield/1', displayName: 'bar', title: 'Start', @@ -957,9 +1015,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 13 + displayOrder: 13, + isAdvancedSearchFieldType: false }, - 'subfield.2': { + 'subfield/2': { name: 'subfield/2', displayName: 'bar', title: 'End', @@ -972,7 +1031,8 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 14 + displayOrder: 14, + isAdvancedSearchFieldType: false }, someNestedKeyWithoutDot: { name: 'someNestedKeyWithoutDot', @@ -987,7 +1047,8 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false }, controlledVocabularyNotMultiple: { name: 'controlledVocabularyNotMultiple', @@ -1003,7 +1064,8 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: false, displayOrder: 10, typeClass: 'controlledVocabulary', - displayOnCreate: true + displayOnCreate: true, + isAdvancedSearchFieldType: false } }, value: [ @@ -1015,7 +1077,7 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] } ] }, - 'composed.field.not.multiple': { + 'composed/field/not/multiple': { name: 'composed/field/not/multiple', displayName: 'Foo', title: 'Foo', @@ -1029,8 +1091,9 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] isRequired: false, displayOnCreate: true, displayOrder: 12, + isAdvancedSearchFieldType: false, childMetadataFields: { - 'subfield.1': { + 'subfield/1': { name: 'subfield/1', displayName: 'bar', title: 'Start', @@ -1043,9 +1106,10 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 13 + displayOrder: 13, + isAdvancedSearchFieldType: false }, - 'subfield.2': { + 'subfield/2': { name: 'subfield/2', displayName: 'bar', title: 'End', @@ -1058,7 +1122,8 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 14 + displayOrder: 14, + isAdvancedSearchFieldType: false }, someNestedKeyWithoutDot: { name: 'someNestedKeyWithoutDot', @@ -1073,7 +1138,8 @@ const normalizedMetadataBlocksInfoWithValues: MetadataBlockInfoWithMaybeValues[] displayFormat: '', isRequired: true, displayOnCreate: true, - displayOrder: 0 + displayOrder: 0, + isAdvancedSearchFieldType: false } }, value: { @@ -1325,7 +1391,7 @@ describe('MetadataFieldsHelper', () => { expect(result).to.deep.equal(normalizedMetadataBlocksInfo) }) it('should replace dot keys with slashes from a Dataset current metadata blocks values ', () => { - const result = MetadataFieldsHelper.replaceDatasetMetadataBlocksCurrentValuesDotKeysWithSlash( + const result = MetadataFieldsHelper.replaceDatasetMetadataBlocksDotKeysWithSlash( datasetMetadaBlocksCurrentValues ) @@ -1385,7 +1451,7 @@ describe('MetadataFieldsHelper', () => { MetadataFieldsHelper.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfo) const inTestNormalizedDatasetMetadaBlocksCurrentValues = - MetadataFieldsHelper.replaceDatasetMetadataBlocksCurrentValuesDotKeysWithSlash( + MetadataFieldsHelper.replaceDatasetMetadataBlocksDotKeysWithSlash( datasetMetadaBlocksCurrentValues ) From 6d294f74ce284048132d00f70bdceac4d8f65256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 5 Sep 2025 13:11:57 -0300 Subject: [PATCH 13/24] fix: issue with facetable fields getting block info from normalized field --- .../CollectionFormHelper.ts | 4 +++- .../EditCreateCollectionForm.tsx | 15 ++++++++++----- src/shared/hooks/useGetAllMetadataBlocksInfo.tsx | 6 +----- .../hooks/useGetAllMetadataBlocksInfo.spec.tsx | 10 +--------- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/sections/shared/form/EditCreateCollectionForm/CollectionFormHelper.ts b/src/sections/shared/form/EditCreateCollectionForm/CollectionFormHelper.ts index cbeda5ce0..ce7515ee9 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/CollectionFormHelper.ts +++ b/src/sections/shared/form/EditCreateCollectionForm/CollectionFormHelper.ts @@ -206,7 +206,9 @@ export class CollectionFormHelper { }, {} as Record) return facetableMetadataFields.map((field) => { - const parentBlockInfo = blockInfoMap[field.name] + // The blockInfo map above has field names normalized with slashes instead of dots. + // So we need to replace dots with slashes before searching for the field in the map. + const parentBlockInfo = blockInfoMap[this.replaceDotWithSlash(field.name)] return { ...field, diff --git a/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx b/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx index 801dd8e18..4e2cf7911 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx +++ b/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx @@ -21,6 +21,8 @@ import { User } from '@/users/domain/models/User' import { CollectionForm } from './collection-form/CollectionForm' import { EditCreateCollectionFormSkeleton } from './EditCreateCollectionFormSkeleton' import { CollectionHelper } from '@/sections/collection/CollectionHelper' +import { MetadataFieldsHelper } from '../DatasetMetadataForm/MetadataFieldsHelper' +import { MetadataBlockInfo } from '@/metadata-block-info/domain/models/MetadataBlockInfo' export const METADATA_BLOCKS_NAMES_GROUPER = 'metadataBlockNames' export const USE_FIELDS_FROM_PARENT = 'useFieldsFromParent' @@ -82,6 +84,9 @@ export const EditCreateCollectionForm = ({ error: allMetadataBlocksInfoError } = useGetAllMetadataBlocksInfo({ metadataBlockInfoRepository }) + const allMetadataBlocksInfoNormalized: MetadataBlockInfo[] = + MetadataFieldsHelper.replaceMetadataBlocksInfoDotNamesKeysWithSlash(allMetadataBlocksInfo) + const { collectionFacets, isLoading: isLoadingCollectionFacets, @@ -100,8 +105,8 @@ export const EditCreateCollectionForm = ({ }) const baseInputLevels: FormattedCollectionInputLevels = useDeepCompareMemo(() => { - return CollectionFormHelper.defineBaseInputLevels(allMetadataBlocksInfo) - }, [allMetadataBlocksInfo]) + return CollectionFormHelper.defineBaseInputLevels(allMetadataBlocksInfoNormalized) + }, [allMetadataBlocksInfoNormalized]) const collectionInputLevelsToFormat = mode === 'edit' ? collection.inputLevels : parentCollection.inputLevels @@ -119,11 +124,11 @@ export const EditCreateCollectionForm = ({ }, [baseInputLevels, formattedCollectionInputLevels]) const baseBlockNames = useDeepCompareMemo(() => { - return allMetadataBlocksInfo.reduce((acc, block) => { + return allMetadataBlocksInfoNormalized.reduce((acc, block) => { acc[block.name] = false return acc }, {} as CollectionFormMetadataBlocks) - }, [allMetadataBlocksInfo]) + }, [allMetadataBlocksInfoNormalized]) const defaultBlocksNames = useDeepCompareMemo( () => @@ -225,7 +230,7 @@ export const EditCreateCollectionForm = ({ collectionRepository={collectionRepository} collectionIdOrParentCollectionId={mode === 'create' ? parentCollection.id : collection.id} defaultValues={formDefaultValues} - allMetadataBlocksInfo={allMetadataBlocksInfo} + allMetadataBlocksInfo={allMetadataBlocksInfoNormalized} allFacetableMetadataFields={facetableMetadataFields} defaultCollectionFacets={defaultCollectionFacets} isEditingRootCollection={isEditingRootCollection} diff --git a/src/shared/hooks/useGetAllMetadataBlocksInfo.tsx b/src/shared/hooks/useGetAllMetadataBlocksInfo.tsx index 1910ea98d..da150394c 100644 --- a/src/shared/hooks/useGetAllMetadataBlocksInfo.tsx +++ b/src/shared/hooks/useGetAllMetadataBlocksInfo.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository' import { getAllMetadataBlocksInfo } from '@/metadata-block-info/domain/useCases/getAllMetadataBlocksInfo' -import { MetadataFieldsHelper } from '@/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper' import { MetadataBlockInfo } from '@/metadata-block-info/domain/models/MetadataBlockInfo' interface Props { @@ -27,10 +26,7 @@ export const useGetAllMetadataBlocksInfo = ({ try { const blocksInfo = await getAllMetadataBlocksInfo(metadataBlockInfoRepository) - const metadataBlocksInfoNormalized: MetadataBlockInfo[] = - MetadataFieldsHelper.replaceMetadataBlocksInfoDotNamesKeysWithSlash(blocksInfo) - - setAllMetadataBlocksInfo(metadataBlocksInfoNormalized) + setAllMetadataBlocksInfo(blocksInfo) } catch (err) { const errorMessage = err instanceof Error && err.message diff --git a/tests/component/shared/hooks/useGetAllMetadataBlocksInfo.spec.tsx b/tests/component/shared/hooks/useGetAllMetadataBlocksInfo.spec.tsx index a2f05c3b2..3091338db 100644 --- a/tests/component/shared/hooks/useGetAllMetadataBlocksInfo.spec.tsx +++ b/tests/component/shared/hooks/useGetAllMetadataBlocksInfo.spec.tsx @@ -1,7 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { MetadataBlockInfoMother } from '@tests/component/metadata-block-info/domain/models/MetadataBlockInfoMother' import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository' -import { MetadataFieldsHelper } from '@/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper' import { useGetAllMetadataBlocksInfo } from '@/shared/hooks/useGetAllMetadataBlocksInfo' const metadataBlockInfoRepository: MetadataBlockInfoRepository = {} as MetadataBlockInfoRepository @@ -25,14 +24,7 @@ describe('useGetAllMetadataBlocksInfo', () => { await act(() => { expect(result.current.isLoading).to.deep.equal(false) - const allMetadataBlocksInfoNormalized = - MetadataFieldsHelper.replaceMetadataBlocksInfoDotNamesKeysWithSlash( - allMetadataBlocksInfoMock - ) - - return expect(result.current.allMetadataBlocksInfo).to.deep.equal( - allMetadataBlocksInfoNormalized - ) + return expect(result.current.allMetadataBlocksInfo).to.deep.equal(allMetadataBlocksInfoMock) }) }) From f3d528beaeb0943999ac1996e2a2e9be272a8fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 5 Sep 2025 16:43:29 -0300 Subject: [PATCH 14/24] feat: add template field instructions --- .../Fields/ComposeFieldMultiple.tsx | 4 +++- .../MetadataFormField/Fields/ComposedField.tsx | 4 +++- .../MetadataFormField/Fields/Primitive.tsx | 4 +++- .../Fields/PrimitiveMultiple.tsx | 4 +++- .../MetadataFormField/Fields/Vocabulary.tsx | 4 +++- .../Fields/VocabularyMultiple.tsx | 4 +++- .../MetadataFormField/index.tsx | 18 +++++++++++++++++- .../MetadataBlockFormFields/index.tsx | 5 ++++- .../DatasetMetadataForm/MetadataForm/index.tsx | 10 ++++++++-- .../shared/form/DatasetMetadataForm/index.tsx | 3 +-- 10 files changed, 48 insertions(+), 12 deletions(-) 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 cf98cb046..f2c6f0ad5 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 @@ -23,7 +23,8 @@ export const ComposedFieldMultiple = ({ description, childMetadataFields, rulesToApply, - notRequiredWithChildFieldsRequired + notRequiredWithChildFieldsRequired, + fieldInstructions }: ComposedFieldMultipleProps) => { const { control } = useFormContext() const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) @@ -79,6 +80,7 @@ export const ComposedFieldMultiple = ({ message={description} required={Boolean(rulesToApply?.required)} titleClassName={styles['composed-field-title']}> + {fieldInstructions && {fieldInstructions}} {notRequiredWithChildFieldsRequired && ( {t('mayBecomeRequired')} 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 970ba4e3d..5c4d90de1 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 @@ -20,7 +20,8 @@ export const ComposedField = ({ description, childMetadataFields, rulesToApply, - notRequiredWithChildFieldsRequired + notRequiredWithChildFieldsRequired, + fieldInstructions }: ComposedFieldProps) => { const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) @@ -56,6 +57,7 @@ export const ComposedField = ({ )} + {fieldInstructions && {fieldInstructions}} {Object.entries(childMetadataFields).map( ([childMetadataFieldKey, childMetadataFieldInfo]) => { 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 b5d1661b6..3c7c64f1a 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 @@ -29,7 +29,8 @@ export const Primitive = ({ withinMultipleFieldsGroup, fieldsArrayIndex, isFieldThatMayBecomeRequired, - childFieldNamesThatTriggerRequired + childFieldNamesThatTriggerRequired, + fieldInstructions }: PrimitiveProps) => { const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) const { control } = useFormContext() @@ -88,6 +89,7 @@ export const Primitive = ({ rules={updatedRulesToApply} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) => ( + {fieldInstructions && {fieldInstructions}} {isTextArea ? ( 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 88b363cf8..35c5bdf7b 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 @@ -21,7 +21,8 @@ export const PrimitiveMultiple = ({ watermark, rulesToApply, metadataBlockName, - compoundParentName + compoundParentName, + fieldInstructions }: PrimitiveMultipleProps) => { const { control } = useFormContext() @@ -76,6 +77,7 @@ export const PrimitiveMultiple = ({ {title} + {fieldInstructions && {fieldInstructions}} {(fieldsArray as { id: string; value: string }[]).map((field, index) => ( { const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) @@ -90,6 +91,7 @@ export const Vocabulary = ({ {title} + {fieldInstructions && {fieldInstructions}} {showSelectWithSearch ? ( 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 39c9bb9f4..6da2c13b4 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 @@ -19,7 +19,8 @@ export const VocabularyMultiple = ({ options, metadataBlockName, compoundParentName, - fieldsArrayIndex + fieldsArrayIndex, + fieldInstructions }: VocabularyProps) => { const { control } = useFormContext() @@ -51,6 +52,7 @@ export const VocabularyMultiple = ({ {title} + {fieldInstructions && {fieldInstructions}} { const { name, @@ -91,6 +97,10 @@ export const MetadataFormField = ({ isParentFieldRequired: compoundParentIsRequired }) + const fieldInstructions: string | undefined = datasetTemplateInstructions?.find( + (i) => i.instructionField === MetadataFieldsHelper.replaceSlashWithDot(name) + )?.instructionText + if (isSafePrimitive) { if (multiple) { return ( @@ -103,6 +113,7 @@ export const MetadataFormField = ({ description={description} rulesToApply={rulesToApply} metadataBlockName={metadataBlockName} + fieldInstructions={fieldInstructions} /> ) } @@ -121,6 +132,7 @@ export const MetadataFormField = ({ withinMultipleFieldsGroup={withinMultipleFieldsGroup} isFieldThatMayBecomeRequired={isFieldThatMayBecomeRequired} childFieldNamesThatTriggerRequired={childFieldNamesThatTriggerRequired} + fieldInstructions={fieldInstructions} /> ) } @@ -139,6 +151,7 @@ export const MetadataFormField = ({ options={controlledVocabularyValues} compoundParentName={compoundParentName} metadataBlockName={metadataBlockName} + fieldInstructions={fieldInstructions} /> ) } @@ -156,6 +169,7 @@ export const MetadataFormField = ({ metadataBlockName={metadataBlockName} compoundParentName={compoundParentName} withinMultipleFieldsGroup={withinMultipleFieldsGroup} + fieldInstructions={fieldInstructions} /> ) } @@ -175,6 +189,7 @@ export const MetadataFormField = ({ compoundParentName={compoundParentName} childMetadataFields={childMetadataFields} notRequiredWithChildFieldsRequired={notRequiredWithChildFieldsRequired} + fieldInstructions={fieldInstructions} /> ) } @@ -192,6 +207,7 @@ export const MetadataFormField = ({ compoundParentName={compoundParentName} childMetadataFields={childMetadataFields} notRequiredWithChildFieldsRequired={notRequiredWithChildFieldsRequired} + fieldInstructions={fieldInstructions} /> ) } diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx index e91094e32..2c70a6fd6 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/index.tsx @@ -1,11 +1,13 @@ +import { DatasetTemplateInstruction } from '@/dataset/domain/models/DatasetTemplate' import { type MetadataBlockInfo } from '../../../../../../metadata-block-info/domain/models/MetadataBlockInfo' import { MetadataFormField } from './MetadataFormField' interface Props { metadataBlock: MetadataBlockInfo + datasetTemplateInstructions?: DatasetTemplateInstruction[] } -export const MetadataBlockFormFields = ({ metadataBlock }: Props) => { +export const MetadataBlockFormFields = ({ metadataBlock, datasetTemplateInstructions }: Props) => { const { metadataFields, name: metadataBlockName } = metadataBlock return ( @@ -16,6 +18,7 @@ export const MetadataBlockFormFields = ({ metadataBlock }: Props) => { key={metadataFieldKey} metadataFieldInfo={metadataFieldInfo} metadataBlockName={metadataBlockName} + datasetTemplateInstructions={datasetTemplateInstructions} /> ) })} diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx index 2896b15ba..d50fe69f2 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/index.tsx @@ -14,6 +14,7 @@ import { RequiredFieldText } from '../../RequiredFieldText/RequiredFieldText' import { RouteWithParams } from '@/sections/Route.enum' import { SeparationLine } from '@/sections/shared/layout/SeparationLine/SeparationLine' import { usePrefillFieldsWithUserData } from './usePrefillFieldsWithUserData' +import { DatasetTemplateInstruction } from '@/dataset/domain/models/DatasetTemplate' import styles from './index.module.scss' interface FormProps { @@ -24,6 +25,7 @@ interface FormProps { datasetRepository: DatasetRepository datasetPersistentID?: string datasetInternalVersionNumber?: number + datasetTemplateInstructions?: DatasetTemplateInstruction[] } export const MetadataForm = ({ @@ -33,7 +35,8 @@ export const MetadataForm = ({ metadataBlocksInfo, datasetRepository, datasetPersistentID, - datasetInternalVersionNumber + datasetInternalVersionNumber, + datasetTemplateInstructions }: FormProps) => { const { user } = useSession() const navigate = useNavigate() @@ -165,7 +168,10 @@ export const MetadataForm = ({ key={metadataBlock.id}> {metadataBlock.displayName} - + ))} diff --git a/src/sections/shared/form/DatasetMetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/index.tsx index cf017f0d2..548e6c62a 100644 --- a/src/sections/shared/form/DatasetMetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/index.tsx @@ -34,8 +34,6 @@ type DatasetMetadataFormProps = export type DatasetMetadataFormMode = 'create' | 'edit' -// TODO:ME - Check we render the form with defaultValues only once. - export const DatasetMetadataForm = ({ mode, collectionId, @@ -110,6 +108,7 @@ export const DatasetMetadataForm = ({ datasetRepository={datasetRepository} datasetPersistentID={datasetPersistentID} datasetInternalVersionNumber={datasetInternalVersionNumber} + datasetTemplateInstructions={datasetTemplate?.instructions} /> ) } From ae7a915b3aeb847f22e246b21400470b2a0db1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 8 Sep 2025 08:24:25 -0300 Subject: [PATCH 15/24] chore: update keycloak dev-env with latest SPI and realm config --- ...iltin-users-authenticator-1.0-SNAPSHOT.jar | Bin 230565 -> 230694 bytes dev-env/keycloak/test-realm.json | 795 +++++++++++------- 2 files changed, 480 insertions(+), 315 deletions(-) diff --git a/dev-env/keycloak/keycloak-dv-builtin-users-authenticator-1.0-SNAPSHOT.jar b/dev-env/keycloak/keycloak-dv-builtin-users-authenticator-1.0-SNAPSHOT.jar index 3aa3ba47ae4b10ec6d98171cfa46b6cacc4ab155..051b8f1b394fbd2c9a6bd7de3496c629afc3f36b 100644 GIT binary patch delta 7138 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 Date: Mon, 8 Sep 2025 09:41:00 -0300 Subject: [PATCH 16/24] test: add unit cases --- src/dataset/domain/models/DatasetTemplate.ts | 4 +- .../DatasetTemplateSelect.tsx | 2 +- .../MetadataFieldsHelper.ts | 13 +- .../create-dataset/CreateDataset.spec.tsx | 94 +++++++ .../DatasetMetadataForm.spec.tsx | 239 +++++++++++++++++- 5 files changed, 338 insertions(+), 14 deletions(-) diff --git a/src/dataset/domain/models/DatasetTemplate.ts b/src/dataset/domain/models/DatasetTemplate.ts index 9b8c1cfea..949090cb9 100644 --- a/src/dataset/domain/models/DatasetTemplate.ts +++ b/src/dataset/domain/models/DatasetTemplate.ts @@ -1,4 +1,4 @@ -import { DatasetLicense, DatasetMetadataBlocks, DatasetTermsOfUse } from './Dataset' +import { DatasetLicense, DatasetMetadataBlock, DatasetTermsOfUse } from './Dataset' export interface DatasetTemplate { id: number @@ -9,7 +9,7 @@ export interface DatasetTemplate { createTime: string createDate: string // πŸ‘‡ From Edit Template Metadata - datasetMetadataBlocks: DatasetMetadataBlocks + datasetMetadataBlocks: DatasetMetadataBlock[] instructions: DatasetTemplateInstruction[] // πŸ‘‡ From Edit Template Terms termsOfUse: DatasetTermsOfUse diff --git a/src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx b/src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx index 0db9c2b04..836172be2 100644 --- a/src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx +++ b/src/sections/create-dataset/dataset-template-select/DatasetTemplateSelect.tsx @@ -30,7 +30,7 @@ export const DatasetTemplateSelect = ({ ) return ( - + { beforeEach(() => { datasetRepository.create = cy.stub().resolves({ persistentId: 'persistentId' }) + datasetRepository.getTemplates = cy.stub().resolves([]) metadataBlockInfoRepository.getDisplayedOnCreateByCollectionId = cy .stub() .resolves(collectionMetadataBlocksInfo) @@ -131,4 +133,96 @@ describe('Create Dataset', () => { ) cy.findAllByTestId('not-allowed-to-create-dataset-alert').should('not.exist') }) + + describe('dataset templates functionality', () => { + it('should not show template select when there are no templates', () => { + cy.customMount( + + ) + cy.findByTestId('dataset-template-select').should('not.exist') + }) + + it('should show template select when there are templates', () => { + const testDatasetTemplate1 = DatasetTemplateMother.create({ + name: 'Template 1', + isDefault: false + }) + datasetRepository.getTemplates = cy.stub().resolves([testDatasetTemplate1]) + + cy.customMount( + + ) + cy.findByTestId('dataset-template-select').should('exist') + + cy.findByText('None').should('exist') // No default template + }) + + it('should set default template when there is one', () => { + const testDatasetTemplate1 = DatasetTemplateMother.create({ + name: 'Template 1', + isDefault: false + }) + const testDatasetTemplate2 = DatasetTemplateMother.create({ + name: 'Template 2', + isDefault: true + }) + datasetRepository.getTemplates = cy + .stub() + .resolves([testDatasetTemplate1, testDatasetTemplate2]) + + cy.customMount( + + ) + cy.findByTestId('dataset-template-select').should('exist') + cy.findByText('None').should('not.exist') + cy.findByText('Template 2').should('exist') // Default template + }) + + it('should change template when user selects another one', () => { + const testDatasetTemplate1 = DatasetTemplateMother.create({ + name: 'Template 1', + isDefault: false + }) + const testDatasetTemplate2 = DatasetTemplateMother.create({ + name: 'Template 2', + isDefault: false + }) + datasetRepository.getTemplates = cy + .stub() + .resolves([testDatasetTemplate1, testDatasetTemplate2]) + + cy.customMount( + + ) + cy.findByTestId('dataset-template-select').should('exist').as('templateSelect') + cy.findByText('None').should('exist') // No default template, None is shown + + cy.get('@templateSelect').within(() => { + cy.findByLabelText('Toggle options menu').click() + cy.findByText('Template 2').click() + }) + + cy.findAllByText('Template 2').should('exist').should('have.length', 2) // Template 2 is selected, we see two + }) + }) }) diff --git a/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx b/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx index 9315bb0e7..bdb867c98 100644 --- a/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx +++ b/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx @@ -8,6 +8,7 @@ import { UserRepository } from '@/users/domain/repositories/UserRepository' import { DatasetMother } from '../../../dataset/domain/models/DatasetMother' import { MetadataBlockInfoMother } from '../../../metadata-block-info/domain/models/MetadataBlockInfoMother' import { UserMother } from '../../../users/domain/models/UserMother' +import { DatasetTemplateMother } from '@tests/component/dataset/domain/models/DatasetTemplateMother' const datasetRepository: DatasetRepository = {} as DatasetRepository const metadataBlockInfoRepository: MetadataBlockInfoRepository = {} as MetadataBlockInfoRepository @@ -51,7 +52,8 @@ const metadataBlocksInfoOnCreateModeWithAstroBlock = isRequired: true, displayOrder: 17, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, 'coverage.ObjectCount': { name: 'coverage.ObjectCount', @@ -66,7 +68,8 @@ const metadataBlocksInfoOnCreateModeWithAstroBlock = isRequired: true, displayOrder: 18, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, someDate: { name: 'someDate', @@ -81,7 +84,8 @@ const metadataBlocksInfoOnCreateModeWithAstroBlock = displayFormat: '', isRequired: false, displayOnCreate: true, - displayOrder: 3 + displayOrder: 3, + isAdvancedSearchFieldType: false } } }) @@ -108,6 +112,7 @@ const metadataBlocksInfoOnCreateModeWithComposedNotMultipleField = displayOrder: 36, typeClass: 'compound', displayOnCreate: false, + isAdvancedSearchFieldType: false, childMetadataFields: { producerName: { name: 'producerName', @@ -123,7 +128,8 @@ const metadataBlocksInfoOnCreateModeWithComposedNotMultipleField = isRequired: true, displayOrder: 37, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false }, producerAffiliation: { name: 'producerAffiliation', @@ -139,7 +145,8 @@ const metadataBlocksInfoOnCreateModeWithComposedNotMultipleField = isRequired: false, displayOrder: 38, typeClass: 'primitive', - displayOnCreate: false + displayOnCreate: false, + isAdvancedSearchFieldType: false } } } @@ -203,6 +210,7 @@ describe('DatasetMetadataForm', () => { datasetRepository.getByPersistentId = cy.stub().resolves(dataset) datasetRepository.create = cy.stub().resolves({ persistentId: 'persistentId' }) datasetRepository.updateMetadata = cy.stub().resolves(undefined) + datasetRepository.getTemplates = cy.stub().resolves([]) metadataBlockInfoRepository.getByCollectionId = cy.stub().resolves(metadataBlocksInfoOnEditMode) metadataBlockInfoRepository.getDisplayedOnCreateByCollectionId = cy .stub() @@ -1907,4 +1915,225 @@ describe('DatasetMetadataForm', () => { }) }) }) + + describe('dataset templates functionality', () => { + const userDisplayName = `${testUser.lastName}, ${testUser.firstName}` + + it('should pre-fill the form fields with template values when a template is selected', () => { + const testTemplate = DatasetTemplateMother.create({ + datasetMetadataBlocks: [ + { + name: 'citation', + fields: { + title: 'Test Template Title', + subject: ['Subject1', 'Subject2'] + } + } + ] + }) + + cy.mountAuthenticated( + + ) + + cy.findByLabelText(/^Title/i).should('have.value', 'Test Template Title') + + cy.findByText('Subject') + .should('exist') + .closest('.row') + .within(() => { + cy.findByLabelText('Toggle options menu').click({ force: true }) + + cy.findByLabelText('Subject1').should('be.checked') + cy.findByLabelText('Subject2').should('be.checked') + }) + + // Assert that user fields are still pre-filled + + cy.findByText('Author') + .closest('.row') + .within(() => { + cy.findByLabelText(/^Name/i).should('have.value', userDisplayName) + }) + }) + + it('should add the subtitle field if it is included in the template and it is not part of the fields for display on create', () => { + const testTemplate = DatasetTemplateMother.create({ + datasetMetadataBlocks: [ + { + name: 'citation', + fields: { + subtitle: 'Test Template Subtitle' + } + } + ] + }) + + cy.mountAuthenticated( + + ) + + cy.findByLabelText(/^Subtitle/i) + .should('exist') + .should('have.value', 'Test Template Subtitle') + }) + + it('should add the field from a metadata block that is not part of the fields for display on create if it is included in the template', () => { + metadataBlockInfoRepository.getByCollectionId = cy + .stub() + .resolves([ + MetadataBlockInfoMother.getCitationBlock(), + MetadataBlockInfoMother.getAstrophysicsBlock() + ]) + metadataBlockInfoRepository.getDisplayedOnCreateByCollectionId = cy + .stub() + .resolves([MetadataBlockInfoMother.getCitationBlock()]) + + const testTemplate = DatasetTemplateMother.create({ + datasetMetadataBlocks: [ + { + name: MetadataBlockName.ASTROPHYSICS, + fields: { + 'coverage.ObjectDensity': '23.35', + 'coverage.ObjectCount': '50' + } + } + ] + }) + + cy.mountAuthenticated( + + ) + + // The astro metadata block is not part of the fields for display on create + // but as the template includes a field from that block, the block should be added to the form and the field should be shown and pre-filled + + // We need to open the astro accordion as it is closed by default and the field is inside it + cy.get('.accordion > :nth-child(2)').within(() => { + // Open accordion and wait for it to open + cy.get('.accordion-button').click() + cy.wait(300) + + cy.findByLabelText(/Object Density/) + .should('exist') + .should('have.value', '23.35') + + cy.findByLabelText(/Object Count/) + .should('exist') + .should('have.value', '50') + }) + }) + + it('should not pre-fill the form fields with user data when those fields are included in the template', () => { + const testTemplate = DatasetTemplateMother.create({ + datasetMetadataBlocks: [ + { + name: 'citation', + fields: { + title: 'Test Template Title', + subject: ['Subject1', 'Subject2'], + author: [ + { + authorAffiliation: 'Template Author Affiliation' + } + ], + datasetContact: [ + { + datasetContactName: 'Template Contact Name' + } + ] + } + } + ] + }) + + cy.mountAuthenticated( + + ) + + cy.findByLabelText(/^Title/i).should('have.value', 'Test Template Title') + + cy.findByText('Subject') + .should('exist') + .closest('.row') + .within(() => { + cy.findByLabelText('Toggle options menu').click({ force: true }) + + cy.findByLabelText('Subject1').should('be.checked') + cy.findByLabelText('Subject2').should('be.checked') + }) + + cy.findByText('Author') + .closest('.row') + .within(() => { + // Author affiliation comes from the template + cy.findByLabelText(/^Affiliation/i).should('have.value', 'Template Author Affiliation') + // Even if author name is not coming from the template, it should not be pre-filled with user data as author affiliation is coming from the template. + cy.findByLabelText(/^Name/i).should('have.value', '') + cy.findByLabelText(/^Name/i).should('not.have.value', userDisplayName) + }) + + cy.findByText('Point of Contact') + .closest('.row') + .within(() => { + // Contact name comes from the template + cy.findByLabelText(/^Name/i).should('have.value', 'Template Contact Name') + // Even if contact email is not coming from the template, it should not be pre-filled with user data as contact name is coming from the template. + cy.findByLabelText(/^E-mail/i).should('have.value', '') + cy.findByLabelText(/^E-mail/i).should('not.have.value', testUser.email) + }) + }) + + it('should show instructions in the form fields', () => { + const testTemplate = DatasetTemplateMother.create({ + instructions: [ + { + instructionField: 'author', + instructionText: 'An author field instruction.' + }, + { + instructionField: 'title', + instructionText: 'A title field instruction.' + } + ] + }) + + cy.mountAuthenticated( + + ) + + cy.findByText('An author field instruction.').should('exist') + cy.findByText('A title field instruction.').should('exist') + }) + }) }) From 4725933bbd1560bfb8864f1ddb1ca48174b2e214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 8 Sep 2025 10:53:21 -0300 Subject: [PATCH 17/24] fix: update test-realm with curator user --- dev-env/keycloak/test-realm.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/dev-env/keycloak/test-realm.json b/dev-env/keycloak/test-realm.json index b7bdbcccc..571a1e80f 100644 --- a/dev-env/keycloak/test-realm.json +++ b/dev-env/keycloak/test-realm.json @@ -436,6 +436,34 @@ "clientRoles": {} } ], + "users": [ + { + "id": "e5531496-cfb8-498c-a902-50c98d649e79", + "createdTimestamp": 1684755721064, + "username": "curator", + "enabled": true, + "totp": false, + "emailVerified": true, + "firstName": "Dataverse", + "lastName": "Curator", + "email": "dataverse-curator@mailinator.com", + "credentials": [ + { + "id": "664546b4-b936-45cf-a4cf-5e98b743fc7f", + "type": "password", + "userLabel": "My password", + "createdDate": 1684755740776, + "secretData": "{\"value\":\"AvVqybCNtCBVAdLEeJKresy9tc3c4BBUQvu5uHVQw4IjVagN6FpKGlDEKOrxhzdSM8skEvthOEqJkloPo1w+NQ==\",\"salt\":\"2em2DDRRlNEYsNR3xDqehw==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-test"], + "notBefore": 0, + "groups": ["/curators"] + } + ], "defaultRole": { "id": "b4ff9091-ddf9-4536-b175-8cfa3e331d71", "name": "default-roles-test", From 33a673e5a2c45b3d173fd462aa44ac95754a43e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 8 Sep 2025 12:36:36 -0300 Subject: [PATCH 18/24] test: improve coverage --- .../advanced-search/AdvancedSearch.spec.tsx | 46 +++++++++++++++++++ .../create-dataset/CreateDataset.spec.tsx | 19 ++++++++ .../DatasetMetadataForm.spec.tsx | 5 ++ 3 files changed, 70 insertions(+) diff --git a/tests/component/sections/advanced-search/AdvancedSearch.spec.tsx b/tests/component/sections/advanced-search/AdvancedSearch.spec.tsx index 3eb1f0b7c..48178c715 100644 --- a/tests/component/sections/advanced-search/AdvancedSearch.spec.tsx +++ b/tests/component/sections/advanced-search/AdvancedSearch.spec.tsx @@ -149,12 +149,14 @@ describe('AdvancedSearch', () => { cy.findByRole('alert').should('be.visible').and('contain.text', errorMessage) }) + it('should submit the form ', () => { cy.customMount( ) cy.findByTestId('advanced-search-metadata-block-citation') @@ -164,4 +166,48 @@ describe('AdvancedSearch', () => { }) cy.findByTestId('submit-button').click() }) + + it('does not render the block if it has no fields for advanced search', () => { + const emptyMetadataBlock = { + id: 5, + name: 'empty-searchable-fields-block', + displayName: 'Block With No Advanced Search Fields', + displayOnCreate: true, + metadataFields: { + foo: { + name: 'foo', + displayName: 'Foo', + title: 'Foo', + type: 'TEXT', + watermark: '', + description: 'A text field.', + multiple: false, + isControlledVocabulary: false, + displayFormat: '', + isRequired: true, + displayOrder: 0, + typeClass: 'primitive', + displayOnCreate: true, + isAdvancedSearchFieldType: false + } + } + } + + metadataBlockInfoRepository.getByCollectionId = cy + .stub() + .resolves([testCitationMetadataBlock, emptyMetadataBlock]) + + cy.customMount( + + ) + + cy.findByTestId('advanced-search-metadata-block-citation').should('be.visible') + cy.findByTestId('advanced-search-metadata-block-empty-searchable-fields-block').should( + 'not.exist' + ) + }) }) diff --git a/tests/component/sections/create-dataset/CreateDataset.spec.tsx b/tests/component/sections/create-dataset/CreateDataset.spec.tsx index eeaceb905..3ba26238b 100644 --- a/tests/component/sections/create-dataset/CreateDataset.spec.tsx +++ b/tests/component/sections/create-dataset/CreateDataset.spec.tsx @@ -16,6 +16,9 @@ const userPermissionsMock = CollectionMother.createUserPermissions() const collectionMetadataBlocksInfo = MetadataBlockInfoMother.getByCollectionIdDisplayedOnCreateTrue() +const metadataBlocksInfoOnEditMode = + MetadataBlockInfoMother.getByCollectionIdDisplayedOnCreateFalse() + const COLLECTION_NAME = 'Collection Name' const collection = CollectionMother.create({ name: COLLECTION_NAME, id: 'test-alias' }) @@ -27,6 +30,8 @@ describe('Create Dataset', () => { .stub() .resolves(collectionMetadataBlocksInfo) + metadataBlockInfoRepository.getByCollectionId = cy.stub().resolves(metadataBlocksInfoOnEditMode) + collectionRepository.getUserPermissions = cy.stub().resolves(userPermissionsMock) collectionRepository.getById = cy.stub().resolves(collection) }) @@ -224,5 +229,19 @@ describe('Create Dataset', () => { cy.findAllByText('Template 2').should('exist').should('have.length', 2) // Template 2 is selected, we see two }) + + it('shows the warning alert when there is an error loading the templates', () => { + datasetRepository.getTemplates = cy.stub().rejects() + + cy.customMount( + + ) + cy.findByText(/Something went wrong getting the dataset templates./) + }) }) }) diff --git a/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx b/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx index bdb867c98..b1b6c0ac9 100644 --- a/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx +++ b/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx @@ -2118,6 +2118,10 @@ describe('DatasetMetadataForm', () => { { instructionField: 'title', instructionText: 'A title field instruction.' + }, + { + instructionField: 'subject', + instructionText: 'A subject field instruction.' } ] }) @@ -2134,6 +2138,7 @@ describe('DatasetMetadataForm', () => { cy.findByText('An author field instruction.').should('exist') cy.findByText('A title field instruction.').should('exist') + cy.findByText('A subject field instruction.').should('exist') }) }) }) From 93ae527b3589890ee58903d79e8ad824d5886a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 10 Sep 2025 08:27:18 -0300 Subject: [PATCH 19/24] refactor: update Dataset and License models for improved type usage --- package-lock.json | 8 ++++---- package.json | 2 +- src/dataset/domain/models/Dataset.ts | 7 ++----- src/dataset/domain/models/DatasetTemplate.ts | 5 +++-- src/licenses/domain/models/License.ts | 14 ++++++++++++++ src/sections/dataset/dataset-terms/License.tsx | 4 ++-- 6 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 src/licenses/domain/models/License.ts diff --git a/package-lock.json b/package-lock.json index 935369faf..63bd02f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.70276ca", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.0ec433f", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-pr355.70276ca", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.70276ca/d33665f9e17225b86445857074d02a72ccb9f9d5", - "integrity": "sha512-D1q1ISnwVcr5C1P9QDa74QGzjWnBZ6Ys0Wda2/JTqIhUQevBEagA2ZAAIaQD/BSN/+ntYGsWg7Eu2XvRc/5bZw==", + "version": "2.0.0-pr355.0ec433f", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.0ec433f/e96da34420285dbaffe888f357812af323ed428c", + "integrity": "sha512-M6QGEFYeH+Mn3cOURRSnk/Kzu08AbJv8xhbI5CLBVTd/SF2Hc2FkEVmXa0vbrfZKa8ELn4k26wS2ub/y1cIS4A==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 30e61e202..6a8713b38 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.70276ca", + "@iqss/dataverse-client-javascript": "2.0.0-pr355.0ec433f", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/src/dataset/domain/models/Dataset.ts b/src/dataset/domain/models/Dataset.ts index 2ee2c67ed..30e10ea5f 100644 --- a/src/dataset/domain/models/Dataset.ts +++ b/src/dataset/domain/models/Dataset.ts @@ -1,6 +1,7 @@ import { Alert, AlertMessageKey } from '../../../alert/domain/models/Alert' import { UpwardHierarchyNode } from '../../../shared/hierarchy/domain/models/UpwardHierarchyNode' import { FileDownloadSize } from '../../../files/domain/models/FileMetadata' +import { License } from '@/licenses/domain/models/License' export enum DatasetLabelSemanticMeaning { DATASET = 'dataset', @@ -191,11 +192,7 @@ interface Software extends DatasetMetadataSubField { softwareVersion?: string } -export interface DatasetLicense { - name: string - uri: string - iconUri?: string -} +export type DatasetLicense = Pick export const defaultLicense: DatasetLicense = { name: 'CC0 1.0', diff --git a/src/dataset/domain/models/DatasetTemplate.ts b/src/dataset/domain/models/DatasetTemplate.ts index 949090cb9..59c315446 100644 --- a/src/dataset/domain/models/DatasetTemplate.ts +++ b/src/dataset/domain/models/DatasetTemplate.ts @@ -1,4 +1,5 @@ -import { DatasetLicense, DatasetMetadataBlock, DatasetTermsOfUse } from './Dataset' +import { License } from '@/licenses/domain/models/License' +import { DatasetMetadataBlock, DatasetTermsOfUse } from './Dataset' export interface DatasetTemplate { id: number @@ -13,7 +14,7 @@ export interface DatasetTemplate { instructions: DatasetTemplateInstruction[] // πŸ‘‡ From Edit Template Terms termsOfUse: DatasetTermsOfUse - license?: DatasetLicense // This license property is going to be present if not custom terms are added in the UI + license?: License // This license property is going to be present if not custom terms are added in the UI } export interface DatasetTemplateInstruction { diff --git a/src/licenses/domain/models/License.ts b/src/licenses/domain/models/License.ts new file mode 100644 index 000000000..7f16442e8 --- /dev/null +++ b/src/licenses/domain/models/License.ts @@ -0,0 +1,14 @@ +export interface License { + id: number + name: string + shortDescription?: string + uri: string + iconUri?: string + active: boolean + isDefault: boolean + sortOrder: number + rightsIdentifier?: string + rightsIdentifierScheme?: string + schemeUri?: string + languageCode?: string +} diff --git a/src/sections/dataset/dataset-terms/License.tsx b/src/sections/dataset/dataset-terms/License.tsx index f640146c9..7f27ca12f 100644 --- a/src/sections/dataset/dataset-terms/License.tsx +++ b/src/sections/dataset/dataset-terms/License.tsx @@ -1,10 +1,10 @@ import { Row, Col } from '@iqss/dataverse-design-system' -import { DatasetLicense as LicenseModel } from '../../../dataset/domain/models/Dataset' +import { DatasetLicense } from '../../../dataset/domain/models/Dataset' import { Trans, useTranslation } from 'react-i18next' import styles from '@/sections/dataset/dataset-terms/DatasetTerms.module.scss' interface LicenseProps { - license?: LicenseModel + license?: DatasetLicense } export function License({ license }: LicenseProps) { From e6eb0067b7ac141651a04cb31b0e2a6927b42641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 10 Sep 2025 12:08:50 -0300 Subject: [PATCH 20/24] chore: update to alpha version of js-dv --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 63bd02f4e..06b3aaf4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.0ec433f", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.66", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-pr355.0ec433f", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr355.0ec433f/e96da34420285dbaffe888f357812af323ed428c", - "integrity": "sha512-M6QGEFYeH+Mn3cOURRSnk/Kzu08AbJv8xhbI5CLBVTd/SF2Hc2FkEVmXa0vbrfZKa8ELn4k26wS2ub/y1cIS4A==", + "version": "2.0.0-alpha.66", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.66/5eac3e19da454f634e409469958c848b70283c16", + "integrity": "sha512-YGDUC/nk2nqmlq5DPNNbnt5KTABZAk+HCLuw90zg/8hWVhU8RSc2fRDeSuc/CQsV/NmCSw6gzhr5FsCsKitdEQ==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 6a8713b38..32861b4ab 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr355.0ec433f", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.66", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", From b5f036ec643d1ce92ab4605b9d0d938bdf197ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 12 Sep 2025 16:24:04 -0300 Subject: [PATCH 21/24] change comment order --- .../shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index 498b8b62e..cf4a18206 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -510,13 +510,14 @@ export class MetadataFieldsHelper { normalizedMetadataBlocksInfoForDisplayOnEdit, normalizedDatasetTemplateMetadataBlocksValues ) + // 3) Add the values from the template to the metadata blocks info for create const metadataBlocksInfoWithValuesFromTemplate = this.addFieldValuesToMetadataBlocksInfo( metadataBlocksInfoWithAddedFieldsFromTemplate, normalizedDatasetTemplateMetadataBlocksValues ) - // 5) Order fields by display order + // 4) Order fields by display order const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder( metadataBlocksInfoWithValuesFromTemplate ) From 337934bbae24164dbe89f6e1f934079e3e46e13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 12 Sep 2025 19:31:22 -0300 Subject: [PATCH 22/24] test: add e2e test --- .../create-dataset/CreateDataset.spec.tsx | 60 +++++++++++++++++++ .../fixtures/new-template-data.json | 46 ++++++++++++++ .../shared/datasets/DatasetHelper.ts | 29 +++++++++ 3 files changed, 135 insertions(+) create mode 100644 tests/e2e-integration/fixtures/new-template-data.json diff --git a/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx b/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx index b4de37091..8b154cae9 100644 --- a/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx +++ b/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx @@ -1,5 +1,6 @@ import { TestsUtils } from '../../../shared/TestsUtils' import { DatasetLabelValue } from '../../../../../src/dataset/domain/models/Dataset' +import { DatasetHelper } from '@tests/e2e-integration/shared/datasets/DatasetHelper' const CREATE_DATASET_PAGE_URL = '/spa/datasets/root/create' @@ -44,6 +45,65 @@ describe('Create Dataset', () => { cy.contains('Agricultural Sciences; Arts and Humanities').should('exist') }) + it('shows template select when a template is available and prefill fields when a template is selected', () => { + cy.wrap(DatasetHelper.createDatasetTemplate(), { timeout: 10000 }).then(() => { + cy.visit(CREATE_DATASET_PAGE_URL) + + cy.wait(1000) + + cy.findByTestId('dataset-template-select').should('exist').as('templateSelect') + cy.findByText('None').should('exist') // No default template, None is shown + + cy.get('@templateSelect').within(() => { + cy.findByLabelText('Toggle options menu').click({ force: true }) + cy.findByText('Dataset Template One').click({ force: true }) + }) + + cy.findByLabelText(/^Title/i).should('have.value', 'Dataset Template One Title') + + cy.findByText('Description') + .closest('.row') + .within(() => { + cy.findByLabelText(/^Text/i).should( + 'have.value', + 'This is the description from Dataset Template One' + ) + }) + + cy.findByText('Subject') + .should('exist') + .closest('.row') + .within(() => { + cy.findByLabelText('Toggle options menu').click({ force: true }) + + cy.findByLabelText('Agricultural Sciences').should('be.checked') + cy.findByLabelText('Arts and Humanities').should('be.checked') + }) + + cy.findByText('Author') + .closest('.row') + .within(() => { + cy.findByLabelText(/^Name/i).should('have.value', 'Belicheck, Bill') + cy.findByLabelText(/^Identifier Type/i).should('have.value', 'ORCID') + }) + + cy.findByText(/Save Dataset/i).click() + + cy.findByRole('heading', { name: 'Dataset Template One Title' }).should('exist') + cy.findByText(/Dataset created successfully./).should('exist') + cy.findByText(DatasetLabelValue.DRAFT).should('exist') + cy.findByText(DatasetLabelValue.UNPUBLISHED).should('exist') + cy.contains('Agricultural Sciences; Arts and Humanities').should('exist') + + // Delete template after test + cy.wrap(DatasetHelper.getDatasetTemplates(), { timeout: 10000 }).then((templates) => { + const { id } = templates[0] + + cy.wrap(DatasetHelper.deleteDatasetTemplate(id)) + }) + }) + }) + it('should redirect the user to the Login page when the user is not authenticated', () => { TestsUtils.logout() diff --git a/tests/e2e-integration/fixtures/new-template-data.json b/tests/e2e-integration/fixtures/new-template-data.json new file mode 100644 index 000000000..f98aef15b --- /dev/null +++ b/tests/e2e-integration/fixtures/new-template-data.json @@ -0,0 +1,46 @@ +{ + "name": "Dataset Template One", + "isDefault": false, + "fields": [ + { + "typeName": "title", + "value": "Dataset Template One Title" + }, + { + "typeName": "dsDescription", + "value": [ + { + "dsDescriptionValue": { + "typeName": "dsDescriptionValue", + "value": "This is the description from Dataset Template One" + } + } + ] + }, + { + "typeName": "subject", + "value": ["Agricultural Sciences", "Arts and Humanities"] + }, + { + "typeName": "author", + "value": [ + { + "authorName": { + "typeName": "authorName", + "value": "Belicheck, Bill" + }, + "authorAffiliation": { + "typeName": "authorIdentifierScheme", + "value": "ORCID" + } + } + ] + } + ], + "instructions": [ + { + "instructionField": "author", + "instructionText": "The author data" + } + ] +} diff --git a/tests/e2e-integration/shared/datasets/DatasetHelper.ts b/tests/e2e-integration/shared/datasets/DatasetHelper.ts index 966b034b1..ea89cbb3a 100644 --- a/tests/e2e-integration/shared/datasets/DatasetHelper.ts +++ b/tests/e2e-integration/shared/datasets/DatasetHelper.ts @@ -1,4 +1,5 @@ import newDatasetData from '../../fixtures/dataset-finch1.json' +import newTemplateData from '../../fixtures/new-template-data.json' import { DataverseApiHelper } from '../DataverseApiHelper' import { FileData } from '../files/FileHelper' import { DatasetLockReason } from '../../../../src/dataset/domain/models/Dataset' @@ -260,4 +261,32 @@ export class DatasetHelper extends DataverseApiHelper { status: string }>(`/datasets/${id}/lock/${reason}`, 'POST') } + + static async createDatasetTemplate(collectionAlias?: string): Promise<{ id: number }> { + if (collectionAlias == undefined) { + collectionAlias = ':root' + } + return this.request<{ id: number }>( + `/dataverses/${collectionAlias}/templates`, + 'POST', + newTemplateData + ) + } + + static async getDatasetTemplates( + collectionIdOrAlias = ROOT_COLLECTION_ALIAS + ): Promise<{ id: number; name: string }[]> { + return this.request<{ id: number; name: string }[]>( + `/dataverses/${collectionIdOrAlias}/templates`, + 'GET' + ) + } + + static async deleteDatasetTemplate(templateId: number): Promise { + try { + return await this.request(`/admin/template/${templateId}`, 'DELETE') + } catch (error) { + throw new Error(`Error while deleting dataset template with id ${templateId}`) + } + } } From d2699d03523732d2de4b73d603849ee95ba86549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 29 Sep 2025 08:12:46 -0300 Subject: [PATCH 23/24] docs: added changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d51575b5d..0cc1a4a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ## [Unreleased] ### Added +- Dataset Templates Selector in the Create Dataset page. ### Changed From ed611564c0ab608e85d146b5661e8630c02b4de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 29 Sep 2025 08:22:59 -0300 Subject: [PATCH 24/24] docs: add to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc1a4a4b..3e69b994b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ## [Unreleased] ### Added + - Dataset Templates Selector in the Create Dataset page. ### Changed