diff --git a/CHANGELOG.md b/CHANGELOG.md index d51575b5d..3e69b994b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Dataset Templates Selector in the Create Dataset page. + ### Changed ### Fixed diff --git a/package-lock.json b/package-lock.json index e6c59d54b..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-alpha.62", + "@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-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-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", @@ -43950,4 +43950,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index d65f4e87a..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-alpha.62", + "@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", diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index e626f796b..1d89983a1 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) }) }) }) 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/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/Dataset.ts b/src/dataset/domain/models/Dataset.ts index 1edb088fc..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', @@ -104,8 +105,8 @@ export interface CitationMetadataBlock extends DatasetMetadataBlock { } interface OtherId extends DatasetMetadataSubField { - otherIdAgency: string - otherIdValue: string + otherIdAgency?: string + otherIdValue?: string } export interface Author extends DatasetMetadataSubField { @@ -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 new file mode 100644 index 000000000..59c315446 --- /dev/null +++ b/src/dataset/domain/models/DatasetTemplate.ts @@ -0,0 +1,23 @@ +import { License } from '@/licenses/domain/models/License' +import { DatasetMetadataBlock, DatasetTermsOfUse } from './Dataset' + +export interface DatasetTemplate { + id: number + name: string + collectionAlias: string + isDefault: boolean + usageCount: number + createTime: string + createDate: string + // ๐Ÿ‘‡ From Edit Template Metadata + datasetMetadataBlocks: DatasetMetadataBlock[] + instructions: DatasetTemplateInstruction[] + // ๐Ÿ‘‡ From Edit Template Terms + termsOfUse: DatasetTermsOfUse + license?: License // This license property is going to be present if not custom terms are added in the UI +} + +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. 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/create-dataset/CreateDataset.tsx b/src/sections/create-dataset/CreateDataset.tsx index b262140b2..986165570 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,23 @@ 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..836172be2 --- /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/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) { diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index d9f6d6251..cf4a18206 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -10,6 +10,7 @@ import { DatasetMetadataChildFieldValueDTO } from '../../../../dataset/domain/useCases/DTOs/DatasetDTO' import { + DatasetMetadataBlock, DatasetMetadataBlocks, DatasetMetadataFields, DatasetMetadataSubField, @@ -29,7 +30,7 @@ type PrimitiveMultipleFormValue = { value: string }[] type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[] -type ComposedSingleFieldValue = Record +export type ComposedSingleFieldValue = Record export class MetadataFieldsHelper { public static replaceMetadataBlocksInfoDotNamesKeysWithSlash( @@ -49,6 +50,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) } @@ -58,14 +65,12 @@ export class MetadataFieldsHelper { } } - public static replaceDatasetMetadataBlocksCurrentValuesDotKeysWithSlash( - datasetMetadataBlocks: DatasetMetadataBlocks - ): DatasetMetadataBlocks { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const datasetMetadataBlocksCopy: DatasetMetadataBlocks = structuredClone(datasetMetadataBlocks) - const dataWithoutKeysWithDots: DatasetMetadataBlocks = [] as unknown as DatasetMetadataBlocks + public static replaceDatasetMetadataBlocksDotKeysWithSlash( + datasetMetadataBlocks: DatasetMetadataBlock[] + ): DatasetMetadataBlock[] { + const dataWithoutKeysWithDots: DatasetMetadataBlock[] = [] as unknown as DatasetMetadataBlock[] - for (const block of datasetMetadataBlocksCopy) { + for (const block of datasetMetadataBlocks) { const newBlockFields: DatasetMetadataFields = this.datasetMetadataBlocksCurrentValuesDotReplacer(block.fields) @@ -383,7 +388,7 @@ export class MetadataFieldsHelper { public static addFieldValuesToMetadataBlocksInfo( normalizedMetadataBlocksInfo: MetadataBlockInfo[], - normalizedDatasetMetadaBlocksCurrentValues: DatasetMetadataBlocks + normalizedDatasetMetadaBlocksCurrentValues: DatasetMetadataBlock[] ): MetadataBlockInfoWithMaybeValues[] { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const normalizedMetadataBlocksInfoCopy: MetadataBlockInfoWithMaybeValues[] = structuredClone( @@ -465,4 +470,176 @@ 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 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( + mode: 'create' | 'edit', + metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[], + metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[], + datasetMetadaBlocksCurrentValues: DatasetMetadataBlocks | undefined, + templateMetadataBlocks: DatasetMetadataBlock[] | 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 + ) + + // 4) 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[], + templateBlocks: DatasetMetadataBlock[] | undefined + ): MetadataBlockInfo[] { + if (!templateBlocks || templateBlocks.length === 0) { + return metadataBlocksInfoForDisplayOnCreate + } + + const createCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfoForDisplayOnCreate) + + const createMap = createCopy.reduce>((acc, block) => { + acc[block.name] = block + return acc + }, {}) + + const editMap = metadataBlocksInfoForDisplayOnEdit.reduce>( + (acc, block) => { + acc[block.name] = block + return acc + }, + {} + ) + + 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 + } + + // We ensure the block exists in the "create" array + 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 + + // 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) { + // The field doesn't exist in "edit" either: there's no way to know its shape; we skip it + 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/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 17d3dfc71..d50fe69f2 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 { 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,8 @@ 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 { DatasetTemplateInstruction } from '@/dataset/domain/models/DatasetTemplate' import styles from './index.module.scss' interface FormProps { @@ -21,10 +22,10 @@ interface FormProps { collectionId: string formDefaultValues: DatasetMetadataFormValues metadataBlocksInfo: MetadataBlockInfo[] - errorLoadingMetadataBlocksInfo: string | null datasetRepository: DatasetRepository datasetPersistentID?: string datasetInternalVersionNumber?: number + datasetTemplateInstructions?: DatasetTemplateInstruction[] } export const MetadataForm = ({ @@ -32,10 +33,10 @@ export const MetadataForm = ({ collectionId, formDefaultValues, metadataBlocksInfo, - errorLoadingMetadataBlocksInfo, datasetRepository, datasetPersistentID, - datasetInternalVersionNumber + datasetInternalVersionNumber, + datasetTemplateInstructions }: FormProps) => { const { user } = useSession() const navigate = useNavigate() @@ -46,10 +47,9 @@ 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 + const { setValue } = form const { submissionStatus, submitError, submitForm } = useSubmitDataset( mode, @@ -60,24 +60,7 @@ export const MetadataForm = ({ 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]) + usePrefillFieldsWithUserData({ mode, user, formDefaultValues, setValue }) const handleCancel = () => { navigate(RouteWithParams.COLLECTIONS(collectionId)) @@ -120,13 +103,7 @@ export const MetadataForm = ({ } } - const disableSubmitButton = useMemo(() => { - return ( - isErrorLoadingMetadataBlocks || - submissionStatus === SubmissionStatus.IsSubmitting || - !formState.isDirty - ) - }, [isErrorLoadingMetadataBlocks, 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 @@ -191,7 +168,10 @@ export const MetadataForm = ({ key={metadataBlock.id}> {metadataBlock.displayName} - + ))} 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..c0118ab42 --- /dev/null +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/usePrefillFieldsWithUserData.ts @@ -0,0 +1,80 @@ +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 (!datasetContact0 && !datasetContactEmail0) { + setValue('citation.datasetContact.0.datasetContactName', displayName) + 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 (!authorName0 && !authorAffiliation0) { + setValue('citation.author.0.authorName', displayName) + } + + if (user.affiliation) { + if (!authorName0 && !datasetContactAffiliation && !authorAffiliation0) { + setValue('citation.datasetContact.0.datasetContactAffiliation', user.affiliation) + setValue('citation.author.0.authorAffiliation', user.affiliation) + } + } + didPrefillRef.current = true + }, [setValue, user, mode, formDefaultValues]) +} diff --git a/src/sections/shared/form/DatasetMetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/index.tsx index 4ebd7aaab..548e6c62a 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' @@ -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,84 +41,74 @@ export const DatasetMetadataForm = ({ datasetPersistentID, metadataBlockInfoRepository, datasetMetadaBlocksCurrentValues, - datasetInternalVersionNumber + datasetInternalVersionNumber, + 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]) - - // 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 - } + const { + metadataBlocksInfo: metadataBlocksInfoForDisplayOnEdit, + isLoading: isLoadingMetadataBlocksInfoForDisplayOnEdit, + error: errorLoadingMetadataBlocksInfoForDisplayOnEdit + } = useGetMetadataBlocksInfo({ + mode: 'edit', + collectionId, + metadataBlockInfoRepository + }) - return onEditMode - ? MetadataFieldsHelper.addFieldValuesToMetadataBlocksInfo( - normalizedMetadataBlocksInfo, - normalizedDatasetMetadaBlocksCurrentValues - ) - : null - }, [normalizedMetadataBlocksInfo, normalizedDatasetMetadaBlocksCurrentValues, onEditMode]) + const isLoadingData = + isLoadingMetadataBlocksInfoForDisplayOnCreate || isLoadingMetadataBlocksInfoForDisplayOnEdit - // 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]) + 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 ( ) } 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/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()) + }) + } } 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..98bab1f3b --- /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', + datasetMetadataBlocks: [] as unknown as DatasetTemplate['datasetMetadataBlocks'], + isDefault: faker.datatype.boolean(), + usageCount: faker.datatype.number({ min: 0, max: 100 }), + instructions: [], + termsOfUse: { + termsOfAccess: { fileAccessRequest: false } + }, + ...props + } + } +} 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 d2c68f9fc..3ba26238b 100644 --- a/tests/component/sections/create-dataset/CreateDataset.spec.tsx +++ b/tests/component/sections/create-dataset/CreateDataset.spec.tsx @@ -5,6 +5,7 @@ import { MetadataBlockInfoMother } from '../../metadata-block-info/domain/models import { NotImplementedModalProvider } from '../../../../src/sections/not-implemented/NotImplementedModalProvider' import { CollectionRepository } from '../../../../src/collection/domain/repositories/CollectionRepository' import { CollectionMother } from '../../collection/domain/models/CollectionMother' +import { DatasetTemplateMother } from '@tests/component/dataset/domain/models/DatasetTemplateMother' const datasetRepository: DatasetRepository = {} as DatasetRepository const metadataBlockInfoRepository: MetadataBlockInfoRepository = {} as MetadataBlockInfoRepository @@ -15,16 +16,22 @@ 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' }) describe('Create Dataset', () => { beforeEach(() => { datasetRepository.create = cy.stub().resolves({ persistentId: 'persistentId' }) + datasetRepository.getTemplates = cy.stub().resolves([]) metadataBlockInfoRepository.getDisplayedOnCreateByCollectionId = cy .stub() .resolves(collectionMetadataBlocksInfo) + metadataBlockInfoRepository.getByCollectionId = cy.stub().resolves(metadataBlocksInfoOnEditMode) + collectionRepository.getUserPermissions = cy.stub().resolves(userPermissionsMock) collectionRepository.getById = cy.stub().resolves(collection) }) @@ -131,4 +138,110 @@ 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 + }) + + 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 9315bb0e7..b1b6c0ac9 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,230 @@ 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.' + }, + { + instructionField: 'subject', + instructionText: 'A subject field instruction.' + } + ] + }) + + cy.mountAuthenticated( + + ) + + cy.findByText('An author field instruction.').should('exist') + cy.findByText('A title field instruction.').should('exist') + cy.findByText('A subject field instruction.').should('exist') + }) + }) }) 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 ) 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) }) }) 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}`) + } + } }