From 0e578fa549cafd994f0c77bafd7cd69463b03ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 2 Oct 2025 09:45:25 -0300 Subject: [PATCH 1/8] fix: improve date field validations --- public/locales/en/shared.json | 14 +- .../domain/models/MetadataBlockInfo.ts | 8 - .../domain/models/fieldValidations.ts | 220 +++++++++++++++--- .../MetadataFormField/useDefineRules.ts | 17 +- 4 files changed, 212 insertions(+), 47 deletions(-) diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index cecdf1252..4a23413bc 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -66,7 +66,19 @@ "email": "{{displayName}} is not a valid email", "int": "{{displayName}} is not a valid integer", "float": "{{displayName}} is not a valid float", - "date": "{{displayName}} is not a valid date. Please use the format {{dateFormat}}" + "date": { + "base": "{{displayName}} is not a valid date.", + "empty": "The date is empty.", + "adDigits": "The AD year must be numeric.", + "adRange": "The AD year cant be higher than 9999.", + "bracketNotNumeric": "The year in brackets must be numeric.", + "bracketNegative": "The year in brackets cannot be negative.", + "bracketRange": "The year in brackets is out of range.", + "invalidMonth": "The month is out of range.", + "invalidDay": "The day is out of range.", + "invalidTime": "The time is out of range.", + "unrecognized": "The date format is unrecognized." + } } }, "status": { diff --git a/src/metadata-block-info/domain/models/MetadataBlockInfo.ts b/src/metadata-block-info/domain/models/MetadataBlockInfo.ts index 606555279..fabe820f2 100644 --- a/src/metadata-block-info/domain/models/MetadataBlockInfo.ts +++ b/src/metadata-block-info/domain/models/MetadataBlockInfo.ts @@ -76,14 +76,6 @@ export const WatermarkMetadataFieldOptions = { export type WatermarkMetadataField = (typeof WatermarkMetadataFieldOptions)[keyof typeof WatermarkMetadataFieldOptions] -export const DateFormatsOptions = { - YYYY: 'YYYY', - YYYYMM: 'YYYY-MM', - YYYYMMDD: 'YYYY-MM-DD' -} as const - -export type DateFormats = (typeof DateFormatsOptions)[keyof typeof DateFormatsOptions] - export interface MetadataBlockInfoDisplayFormat { name: string displayName: string diff --git a/src/metadata-block-info/domain/models/fieldValidations.ts b/src/metadata-block-info/domain/models/fieldValidations.ts index 990390c24..891bd654d 100644 --- a/src/metadata-block-info/domain/models/fieldValidations.ts +++ b/src/metadata-block-info/domain/models/fieldValidations.ts @@ -1,5 +1,3 @@ -import { DateFormats } from './MetadataBlockInfo' - export function isValidEmail(email: string): boolean { const EMAIL_REGEX = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])/ @@ -49,34 +47,6 @@ function isValidHostname(hostname: string): boolean { return hostnameRegex.test(hostname) } -export function isValidDateFormat(dateString: string, acceptedFormat?: DateFormats): boolean { - // Regular expression for YYYY-MM-DD format - const dateFormatRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/ - // Regular expression for YYYY-MM format - const yearMonthFormatRegex = /^\d{4}-(0[1-9]|1[0-2])$/ - // Regular expression for YYYY format - const yearFormatRegex = /^\d{4}$/ - - if (acceptedFormat) { - if (acceptedFormat === 'YYYY-MM-DD') { - return dateFormatRegex.test(dateString) - } - - if (acceptedFormat === 'YYYY-MM') { - return yearMonthFormatRegex.test(dateString) - } - - return yearFormatRegex.test(dateString) - } else { - // Check if it matches any of the formats - return ( - dateFormatRegex.test(dateString) || - yearMonthFormatRegex.test(dateString) || - yearFormatRegex.test(dateString) - ) - } -} - /** * Extracts the invalid value from the error message, removing 'Validation Failed:' and everything after '(Invalid value:' * @param errorMessage @@ -103,3 +73,193 @@ export function getValidationFailedFieldError(errorMessage: string): string | nu return null } + +type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP' + +/** Stable error codes for i18n mapping */ +export const dateKeyMessageErrorMap = { + E_EMPTY: 'field.invalid.date.empty', + E_AD_DIGITS: 'field.invalid.date.adDigits', + E_AD_RANGE: 'field.invalid.date.adRange', + E_BC_NOT_NUMERIC: 'field.invalid.date.bcNotNumeric', + E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative', + E_BRACKET_NOT_NUM: 'field.invalid.date.bracketNotNumeric', + E_BRACKET_RANGE: 'field.invalid.date.bracketRange', + E_INVALID_MONTH: 'field.invalid.date.invalidMonth', + E_INVALID_DAY: 'field.invalid.date.invalidDay', + E_INVALID_TIME: 'field.invalid.date.invalidTime', + E_UNRECOGNIZED: 'field.invalid.date.unrecognized' +} as const + +type DateErrorCode = keyof typeof dateKeyMessageErrorMap + +type Validation = { valid: true; kind: DateLikeKind } | { valid: false; errorCode: DateErrorCode } + +export function isValidDateFormat(input: string): Validation { + if (!input) return err('E_EMPTY') + + const s = input.trim() + + // 1) yyyy-MM-dd, yyyy-MM, yyyy + if (isValidDateAgainstPattern(s, 'yyyy-MM-dd')) return ok('YMD') + if (isValidDateAgainstPattern(s, 'yyyy-MM')) return ok('YM') + if (isValidDateAgainstPattern(s, 'yyyy')) return ok('Y') + + // 2) Bracketed: starts "[" ends "?]" and not "[-" + // 2) Bracketed: starts "[" ends "?]" and not "[-" + if (s.startsWith('[') && s.endsWith('?]')) { + if (s.startsWith('[-')) return err('E_BRACKET_NEGATIVE') + + const core = s + .replace(/\[|\?\]|-|(?:AD|BC)/g, ' ') + .trim() + .replace(/\s+/g, ' ') + + if (s.includes('BC')) { + if (!/^\d+$/.test(core)) return err('E_BRACKET_NOT_NUM') + return ok('BRACKET') + } else { + if (!/^\d+$/.test(core)) return err('E_BRACKET_NOT_NUM') + // AD/unspecified branch: valid 1–4 digit year between 0 and 9999 + if (!/^\d{1,4}$/.test(core)) return err('E_BRACKET_RANGE') + if (!isValidDateAgainstPattern(core, 'yyyy')) return err('E_BRACKET_RANGE') + return ok('BRACKET') + } + } + + /// 3) AD: strip AD (with or without space) + if (s.includes('AD')) { + const before = s.substring(0, s.indexOf('AD')).trim() + + // must be numeric + if (!/^\d+$/.test(before)) return err('E_AD_DIGITS') + + // >4 digits or >9999 → range violation (choose one code; using E_AD_RANGE as you expect) + if (before.length > 4 || Number(before) > 9999) return err('E_AD_RANGE') + + // final strict check mirroring Java's SimpleDateFormat("yyyy") + lenient(false) + if (!isValidDateAgainstPattern(before, 'yyyy')) return err('E_AD_RANGE') + + return ok('AD') + } + + // 4) BC: strip BC, numeric only (no explicit max in JSF) + if (s.includes('BC')) { + const before = s.substring(0, s.indexOf('BC')).trim() + + if (!/^\d+$/.test(before)) return err('E_BC_NOT_NUMERIC') + + return ok('BC') + } + + // 5) Timestamp fallbacks (temporary) + if (isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return ok('TIMESTAMP') + if (isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return ok('TIMESTAMP') + if (isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return ok('TIMESTAMP') + + // Targeted error codes for common shapes + if (/^\d+$/.test(s) && s.length > 4) return err('E_AD_RANGE') + if (/^\d{4}-(\d{2})$/.test(s)) return err('E_INVALID_MONTH') + if (/^\d{4}-(\d{2})-(\d{2})$/.test(s)) return err('E_INVALID_DAY') + if (/^\d{4}-\d{2}-\d{2}T/.test(s) || /^\d{4}-\d{2}-\d{2} /.test(s)) return err('E_INVALID_TIME') + + return err('E_UNRECOGNIZED') +} + +/* ---------------- Helpers (port of Java isValidDate with setLenient(false)) ---------------- */ + +function isValidDateAgainstPattern(dateString: string, pattern: string): boolean { + if (!dateString) return false + const s = dateString.trim() + if (s.length > pattern.length) return false + + switch (pattern) { + case 'yyyy': { + if (!/^\d{1,4}$/.test(s)) return false + const year = Number(s) + return year >= 0 && year <= 9999 + } + case 'yyyy-MM': { + const m = /^(\d{1,4})-(\d{2})$/.exec(s) + if (!m) return false + const year = Number(m[1]) + const month = Number(m[2]) + if (!(year >= 0 && year <= 9999)) return false + return month >= 1 && month <= 12 + } + case 'yyyy-MM-dd': { + const m = /^(\d{1,4})-(\d{2})-(\d{2})$/.exec(s) + if (!m) return false + const year = Number(m[1]) + const month = Number(m[2]) + const day = Number(m[3]) + if (!(year >= 0 && year <= 9999)) return false + if (!(month >= 1 && month <= 12)) return false + const dim = daysInMonth(year, month) + return day >= 1 && day <= dim + } + case "yyyy-MM-dd'T'HH:mm:ss": { + const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(s) + + if (!m) return false + + const [, y, mo, d, hh, mm, ss] = m + + if (!isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false + + return isValidHMS(hh, mm, ss) + } + case "yyyy-MM-dd'T'HH:mm:ss.SSS": { + const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s) + + if (!m) return false + + const [, y, mo, d, hh, mm, ss, ms] = m + + if (!isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false + + return isValidHMS(hh, mm, ss) && /^\d{3}$/.test(ms) + } + case 'yyyy-MM-dd HH:mm:ss': { + const m = /^(\d{1,4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(s) + + if (!m) return false + + const [, y, mo, d, hh, mm, ss] = m + + if (!isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false + + return isValidHMS(hh, mm, ss) + } + default: + return false + } +} + +function isValidHMS(hh: string, mm: string, ss: string): boolean { + const H = Number(hh), + M = Number(mm), + S = Number(ss) + return H >= 0 && H <= 23 && M >= 0 && M <= 59 && S >= 0 && S <= 59 +} + +// prettier-ignore +function daysInMonth(y: number, m: number): number { + switch (m) { + case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31 + case 4: case 6: case 9: case 11: return 30 + case 2: return isLeapYear(y) ? 29 : 28 + default: return 0 + } +} + +function isLeapYear(y: number): boolean { + return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 +} + +function ok(kind: DateLikeKind): Validation { + return { valid: true, kind } +} +function err(code: DateErrorCode): Validation { + return { valid: false, errorCode: code } +} diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts index 9358c9907..ed37edb6b 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts @@ -1,7 +1,6 @@ import { UseControllerProps } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { - DateFormatsOptions, type MetadataField, TypeMetadataFieldOptions } from '../../../../../../../metadata-block-info/domain/models/MetadataBlockInfo' @@ -10,7 +9,8 @@ import { isValidFloat, isValidEmail, isValidInteger, - isValidDateFormat + isValidDateFormat, + dateKeyMessageErrorMap } from '../../../../../../../metadata-block-info/domain/models/fieldValidations' interface Props { @@ -25,7 +25,7 @@ export const useDefineRules = ({ isParentFieldRequired }: Props): DefinedRules => { const { t } = useTranslation('shared', { keyPrefix: 'datasetMetadataForm' }) - const { type, displayName, isRequired, watermark } = metadataFieldInfo + const { type, displayName, isRequired } = metadataFieldInfo // A sub field is required if the parent field is required and the sub field is required const isFieldRequired = @@ -47,15 +47,16 @@ export const useDefineRules = ({ return true } if (type === TypeMetadataFieldOptions.Date) { - const acceptedDateFormat = - watermark === 'YYYY-MM-DD' ? DateFormatsOptions.YYYYMMDD : undefined + const validationResult = isValidDateFormat(value) - if (!isValidDateFormat(value, acceptedDateFormat)) { - return t('field.invalid.date', { + if (!validationResult.valid) { + const baseMessage = t('field.invalid.date.base', { displayName, - dateFormat: watermark, interpolation: { escapeValue: false } }) + const specificErrorMessage = t(dateKeyMessageErrorMap[validationResult.errorCode]) + + return `${baseMessage} ${specificErrorMessage}` } return true } From 2a16f52c48b46e35ae830db943c55c06a22e3436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 2 Oct 2025 11:13:36 -0300 Subject: [PATCH 2/8] refactor: replace field validation functions with Validator and MetadataFieldsHelper class methods --- .../domain/models/fieldValidations.ts | 265 ------------------ .../DeaccessionDatasetModal.tsx | 4 +- .../MetadataFieldsHelper.ts | 215 ++++++++++++++ .../MetadataFormField/useDefineRules.ts | 20 +- .../DatasetMetadataForm/useSubmitDataset.ts | 5 +- src/shared/helpers/Validator.ts | 39 +++ .../EditDatasetMetadata.spec.tsx | 5 + 7 files changed, 270 insertions(+), 283 deletions(-) delete mode 100644 src/metadata-block-info/domain/models/fieldValidations.ts diff --git a/src/metadata-block-info/domain/models/fieldValidations.ts b/src/metadata-block-info/domain/models/fieldValidations.ts deleted file mode 100644 index 891bd654d..000000000 --- a/src/metadata-block-info/domain/models/fieldValidations.ts +++ /dev/null @@ -1,265 +0,0 @@ -export function isValidEmail(email: string): boolean { - const EMAIL_REGEX = - /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])/ - return EMAIL_REGEX.test(email) -} - -export function isValidInteger(value: string): boolean { - const INTEGER_REGEX = /^\d+$/ - return INTEGER_REGEX.test(value) -} - -export function isValidFloat(value: string): boolean { - const FLOAT_REGEX = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/ - return FLOAT_REGEX.test(value) -} - -type AcceptedProtocols = 'http' | 'https' | 'ftp' -export function isValidURL(url: string, specificProtocols?: AcceptedProtocols[]): boolean { - try { - const urlObj = new URL(url) - - const acceptedProtocols: AcceptedProtocols[] = ['http', 'https', 'ftp'] - - if (!acceptedProtocols.includes(urlObj.protocol.slice(0, -1) as AcceptedProtocols)) { - return false - } - - if ( - specificProtocols && - !specificProtocols.includes(urlObj.protocol.slice(0, -1) as AcceptedProtocols) - ) { - return false - } - - if (!isValidHostname(urlObj.hostname)) { - return false - } - - return true - } catch (_error) { - return false - } -} - -function isValidHostname(hostname: string): boolean { - const hostnameRegex = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ - return hostnameRegex.test(hostname) -} - -/** - * Extracts the invalid value from the error message, removing 'Validation Failed:' and everything after '(Invalid value:' - * @param errorMessage - * @returns the invalid value or null if it can't be extracted - * @example - * getValidationFailedFieldError("Validation Failed: Point of Contact E-mail test@test.c is not a valid email address. (Invalid value:edu.harvard.iq.dataverse.DatasetFieldValueValue[ id=null ]).java.util.stream.ReferencePipeline$3@561b5200") - * // returns "Point of Contact E-mail test@test.c is not a valid email address." - */ - -export function getValidationFailedFieldError(errorMessage: string): string | null { - const validationFailedKeyword = 'Validation Failed:' - const invalidValueKeyword = '(Invalid value:' - - const validationFailedKeywordIndex = errorMessage.indexOf(validationFailedKeyword) - const invalidValueKeywordIndex = errorMessage.indexOf(invalidValueKeyword) - - if (validationFailedKeywordIndex !== -1 && invalidValueKeywordIndex !== -1) { - const start = validationFailedKeywordIndex + validationFailedKeyword.length - const end = invalidValueKeywordIndex - const extractedValue = errorMessage.slice(start, end).trim() - - return extractedValue - } - - return null -} - -type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP' - -/** Stable error codes for i18n mapping */ -export const dateKeyMessageErrorMap = { - E_EMPTY: 'field.invalid.date.empty', - E_AD_DIGITS: 'field.invalid.date.adDigits', - E_AD_RANGE: 'field.invalid.date.adRange', - E_BC_NOT_NUMERIC: 'field.invalid.date.bcNotNumeric', - E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative', - E_BRACKET_NOT_NUM: 'field.invalid.date.bracketNotNumeric', - E_BRACKET_RANGE: 'field.invalid.date.bracketRange', - E_INVALID_MONTH: 'field.invalid.date.invalidMonth', - E_INVALID_DAY: 'field.invalid.date.invalidDay', - E_INVALID_TIME: 'field.invalid.date.invalidTime', - E_UNRECOGNIZED: 'field.invalid.date.unrecognized' -} as const - -type DateErrorCode = keyof typeof dateKeyMessageErrorMap - -type Validation = { valid: true; kind: DateLikeKind } | { valid: false; errorCode: DateErrorCode } - -export function isValidDateFormat(input: string): Validation { - if (!input) return err('E_EMPTY') - - const s = input.trim() - - // 1) yyyy-MM-dd, yyyy-MM, yyyy - if (isValidDateAgainstPattern(s, 'yyyy-MM-dd')) return ok('YMD') - if (isValidDateAgainstPattern(s, 'yyyy-MM')) return ok('YM') - if (isValidDateAgainstPattern(s, 'yyyy')) return ok('Y') - - // 2) Bracketed: starts "[" ends "?]" and not "[-" - // 2) Bracketed: starts "[" ends "?]" and not "[-" - if (s.startsWith('[') && s.endsWith('?]')) { - if (s.startsWith('[-')) return err('E_BRACKET_NEGATIVE') - - const core = s - .replace(/\[|\?\]|-|(?:AD|BC)/g, ' ') - .trim() - .replace(/\s+/g, ' ') - - if (s.includes('BC')) { - if (!/^\d+$/.test(core)) return err('E_BRACKET_NOT_NUM') - return ok('BRACKET') - } else { - if (!/^\d+$/.test(core)) return err('E_BRACKET_NOT_NUM') - // AD/unspecified branch: valid 1–4 digit year between 0 and 9999 - if (!/^\d{1,4}$/.test(core)) return err('E_BRACKET_RANGE') - if (!isValidDateAgainstPattern(core, 'yyyy')) return err('E_BRACKET_RANGE') - return ok('BRACKET') - } - } - - /// 3) AD: strip AD (with or without space) - if (s.includes('AD')) { - const before = s.substring(0, s.indexOf('AD')).trim() - - // must be numeric - if (!/^\d+$/.test(before)) return err('E_AD_DIGITS') - - // >4 digits or >9999 → range violation (choose one code; using E_AD_RANGE as you expect) - if (before.length > 4 || Number(before) > 9999) return err('E_AD_RANGE') - - // final strict check mirroring Java's SimpleDateFormat("yyyy") + lenient(false) - if (!isValidDateAgainstPattern(before, 'yyyy')) return err('E_AD_RANGE') - - return ok('AD') - } - - // 4) BC: strip BC, numeric only (no explicit max in JSF) - if (s.includes('BC')) { - const before = s.substring(0, s.indexOf('BC')).trim() - - if (!/^\d+$/.test(before)) return err('E_BC_NOT_NUMERIC') - - return ok('BC') - } - - // 5) Timestamp fallbacks (temporary) - if (isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return ok('TIMESTAMP') - if (isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return ok('TIMESTAMP') - if (isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return ok('TIMESTAMP') - - // Targeted error codes for common shapes - if (/^\d+$/.test(s) && s.length > 4) return err('E_AD_RANGE') - if (/^\d{4}-(\d{2})$/.test(s)) return err('E_INVALID_MONTH') - if (/^\d{4}-(\d{2})-(\d{2})$/.test(s)) return err('E_INVALID_DAY') - if (/^\d{4}-\d{2}-\d{2}T/.test(s) || /^\d{4}-\d{2}-\d{2} /.test(s)) return err('E_INVALID_TIME') - - return err('E_UNRECOGNIZED') -} - -/* ---------------- Helpers (port of Java isValidDate with setLenient(false)) ---------------- */ - -function isValidDateAgainstPattern(dateString: string, pattern: string): boolean { - if (!dateString) return false - const s = dateString.trim() - if (s.length > pattern.length) return false - - switch (pattern) { - case 'yyyy': { - if (!/^\d{1,4}$/.test(s)) return false - const year = Number(s) - return year >= 0 && year <= 9999 - } - case 'yyyy-MM': { - const m = /^(\d{1,4})-(\d{2})$/.exec(s) - if (!m) return false - const year = Number(m[1]) - const month = Number(m[2]) - if (!(year >= 0 && year <= 9999)) return false - return month >= 1 && month <= 12 - } - case 'yyyy-MM-dd': { - const m = /^(\d{1,4})-(\d{2})-(\d{2})$/.exec(s) - if (!m) return false - const year = Number(m[1]) - const month = Number(m[2]) - const day = Number(m[3]) - if (!(year >= 0 && year <= 9999)) return false - if (!(month >= 1 && month <= 12)) return false - const dim = daysInMonth(year, month) - return day >= 1 && day <= dim - } - case "yyyy-MM-dd'T'HH:mm:ss": { - const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(s) - - if (!m) return false - - const [, y, mo, d, hh, mm, ss] = m - - if (!isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false - - return isValidHMS(hh, mm, ss) - } - case "yyyy-MM-dd'T'HH:mm:ss.SSS": { - const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s) - - if (!m) return false - - const [, y, mo, d, hh, mm, ss, ms] = m - - if (!isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false - - return isValidHMS(hh, mm, ss) && /^\d{3}$/.test(ms) - } - case 'yyyy-MM-dd HH:mm:ss': { - const m = /^(\d{1,4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(s) - - if (!m) return false - - const [, y, mo, d, hh, mm, ss] = m - - if (!isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false - - return isValidHMS(hh, mm, ss) - } - default: - return false - } -} - -function isValidHMS(hh: string, mm: string, ss: string): boolean { - const H = Number(hh), - M = Number(mm), - S = Number(ss) - return H >= 0 && H <= 23 && M >= 0 && M <= 59 && S >= 0 && S <= 59 -} - -// prettier-ignore -function daysInMonth(y: number, m: number): number { - switch (m) { - case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31 - case 4: case 6: case 9: case 11: return 30 - case 2: return isLeapYear(y) ? 29 : 28 - default: return 0 - } -} - -function isLeapYear(y: number): boolean { - return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 -} - -function ok(kind: DateLikeKind): Validation { - return { valid: true, kind } -} -function err(code: DateErrorCode): Validation { - return { valid: false, errorCode: code } -} diff --git a/src/sections/dataset/deaccession-dataset/DeaccessionDatasetModal.tsx b/src/sections/dataset/deaccession-dataset/DeaccessionDatasetModal.tsx index 56c0b8bb9..043982047 100644 --- a/src/sections/dataset/deaccession-dataset/DeaccessionDatasetModal.tsx +++ b/src/sections/dataset/deaccession-dataset/DeaccessionDatasetModal.tsx @@ -7,7 +7,7 @@ import { Alert, Button, Col, Form, Modal, Stack } from '@iqss/dataverse-design-s import { useDataset } from '../DatasetContext' import { Deaccessioned } from '@/dataset/domain/models/DatasetVersionSummaryInfo' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' -import { isValidURL } from '@/metadata-block-info/domain/models/fieldValidations' +import { Validator } from '@/shared/helpers/Validator' import { useGetDatasetVersionsSummaries } from '../dataset-versions/useGetDatasetVersionsSummaries' import { DeaccessionFormData } from './DeaccessionFormData' import { ConfirmationModal } from './ConfirmationModal' @@ -100,7 +100,7 @@ export function DeaccessionDatasetModal({ if (value.trim() === '') { return true // Consider empty strings as valid } - return isValidURL(value) + return Validator.isValidURL(value) } const handleCloseWithReset = () => { diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index cf4a18206..6b16561f0 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -32,6 +32,29 @@ type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[] export type ComposedSingleFieldValue = Record +type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP' + +/** Stable error codes for i18n mapping */ +export const dateKeyMessageErrorMap = { + E_EMPTY: 'field.invalid.date.empty', + E_AD_DIGITS: 'field.invalid.date.adDigits', + E_AD_RANGE: 'field.invalid.date.adRange', + E_BC_NOT_NUMERIC: 'field.invalid.date.bcNotNumeric', + E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative', + E_BRACKET_NOT_NUM: 'field.invalid.date.bracketNotNumeric', + E_BRACKET_RANGE: 'field.invalid.date.bracketRange', + E_INVALID_MONTH: 'field.invalid.date.invalidMonth', + E_INVALID_DAY: 'field.invalid.date.invalidDay', + E_INVALID_TIME: 'field.invalid.date.invalidTime', + E_UNRECOGNIZED: 'field.invalid.date.unrecognized' +} as const + +type DateErrorCode = keyof typeof dateKeyMessageErrorMap + +type DateValidation = + | { valid: true; kind: DateLikeKind } + | { valid: false; errorCode: DateErrorCode } + export class MetadataFieldsHelper { public static replaceMetadataBlocksInfoDotNamesKeysWithSlash( metadataBlocks: MetadataBlockInfo[] @@ -642,4 +665,196 @@ export class MetadataFieldsHelper { } return metadataBlocksInfoCopy } + + /** + * Extracts the invalid value from the error message, removing 'Validation Failed:' and everything after '(Invalid value:' + * @param errorMessage + * @returns the invalid value or null if it can't be extracted + * @example + * getValidationFailedFieldError("Validation Failed: Point of Contact E-mail test@test.c is not a valid email address. (Invalid value:edu.harvard.iq.dataverse.DatasetFieldValueValue[ id=null ]).java.util.stream.ReferencePipeline$3@561b5200") + * // returns "Point of Contact E-mail test@test.c is not a valid email address." + */ + + public static getValidationFailedFieldError(errorMessage: string): string | null { + const validationFailedKeyword = 'Validation Failed:' + const invalidValueKeyword = '(Invalid value:' + + const validationFailedKeywordIndex = errorMessage.indexOf(validationFailedKeyword) + const invalidValueKeywordIndex = errorMessage.indexOf(invalidValueKeyword) + + if (validationFailedKeywordIndex !== -1 && invalidValueKeywordIndex !== -1) { + const start = validationFailedKeywordIndex + validationFailedKeyword.length + const end = invalidValueKeywordIndex + const extractedValue = errorMessage.slice(start, end).trim() + + return extractedValue + } + + return null + } + + public static isValidDateFormat(input: string): DateValidation { + if (!input) return this.err('E_EMPTY') + + const s = input.trim() + + // 1) yyyy-MM-dd, yyyy-MM, yyyy + if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd')) return this.ok('YMD') + if (this.isValidDateAgainstPattern(s, 'yyyy-MM')) return this.ok('YM') + if (this.isValidDateAgainstPattern(s, 'yyyy')) return this.ok('Y') + + // 2) Bracketed: starts "[" ends "?]" and not "[-" + if (s.startsWith('[') && s.endsWith('?]')) { + if (s.startsWith('[-')) return this.err('E_BRACKET_NEGATIVE') + + const core = s + .replace(/\[|\?\]|-|(?:AD|BC)/g, ' ') + .trim() + .replace(/\s+/g, ' ') + + if (s.includes('BC')) { + if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM') + + return this.ok('BRACKET') + } else { + if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM') + + if (!/^\d{1,4}$/.test(core)) return this.err('E_BRACKET_RANGE') + + if (!this.isValidDateAgainstPattern(core, 'yyyy')) return this.err('E_BRACKET_RANGE') + + return this.ok('BRACKET') + } + } + + /// 3) AD: strip AD (with or without space) + if (s.includes('AD')) { + const before = s.substring(0, s.indexOf('AD')).trim() + + if (!/^\d+$/.test(before)) return this.err('E_AD_DIGITS') + + if (before.length > 4 || Number(before) > 9999) return this.err('E_AD_RANGE') + + if (!this.isValidDateAgainstPattern(before, 'yyyy')) return this.err('E_AD_RANGE') + + return this.ok('AD') + } + + // 4) BC: strip BC, numeric only (no explicit max in JSF) + if (s.includes('BC')) { + const before = s.substring(0, s.indexOf('BC')).trim() + + if (!/^\d+$/.test(before)) return this.err('E_BC_NOT_NUMERIC') + + return this.ok('BC') + } + + if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return this.ok('TIMESTAMP') + if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return this.ok('TIMESTAMP') + if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return this.ok('TIMESTAMP') + + if (/^\d+$/.test(s) && s.length > 4) return this.err('E_AD_RANGE') + if (/^\d{4}-(\d{2})$/.test(s)) return this.err('E_INVALID_MONTH') + if (/^\d{4}-(\d{2})-(\d{2})$/.test(s)) return this.err('E_INVALID_DAY') + if (/^\d{4}-\d{2}-\d{2}T/.test(s) || /^\d{4}-\d{2}-\d{2} /.test(s)) + return this.err('E_INVALID_TIME') + + return this.err('E_UNRECOGNIZED') + } + + public static isValidDateAgainstPattern(dateString: string, pattern: string): boolean { + if (!dateString) return false + const s = dateString.trim() + if (s.length > pattern.length) return false + + switch (pattern) { + case 'yyyy': { + if (!/^\d{1,4}$/.test(s)) return false + const year = Number(s) + return year >= 0 && year <= 9999 + } + case 'yyyy-MM': { + const m = /^(\d{1,4})-(\d{2})$/.exec(s) + if (!m) return false + const year = Number(m[1]) + const month = Number(m[2]) + if (!(year >= 0 && year <= 9999)) return false + return month >= 1 && month <= 12 + } + case 'yyyy-MM-dd': { + const m = /^(\d{1,4})-(\d{2})-(\d{2})$/.exec(s) + if (!m) return false + const year = Number(m[1]) + const month = Number(m[2]) + const day = Number(m[3]) + if (!(year >= 0 && year <= 9999)) return false + if (!(month >= 1 && month <= 12)) return false + const dim = this.daysInMonth(year, month) + return day >= 1 && day <= dim + } + case "yyyy-MM-dd'T'HH:mm:ss": { + const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(s) + + if (!m) return false + + const [, y, mo, d, hh, mm, ss] = m + + if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false + + return this.isValidHMS(hh, mm, ss) + } + case "yyyy-MM-dd'T'HH:mm:ss.SSS": { + const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s) + + if (!m) return false + + const [, y, mo, d, hh, mm, ss, ms] = m + + if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false + + return this.isValidHMS(hh, mm, ss) && /^\d{3}$/.test(ms) + } + case 'yyyy-MM-dd HH:mm:ss': { + const m = /^(\d{1,4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(s) + + if (!m) return false + + const [, y, mo, d, hh, mm, ss] = m + + if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false + + return this.isValidHMS(hh, mm, ss) + } + default: + return false + } + } + + public static isValidHMS(hh: string, mm: string, ss: string): boolean { + const H = Number(hh), + M = Number(mm), + S = Number(ss) + return H >= 0 && H <= 23 && M >= 0 && M <= 59 && S >= 0 && S <= 59 + } + + // prettier-ignore + public static daysInMonth(y: number, m: number): number { + switch (m) { + case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31 + case 4: case 6: case 9: case 11: return 30 + case 2: return this.isLeapYear(y) ? 29 : 28 + default: return 0 + } + } + + public static isLeapYear(y: number): boolean { + return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 + } + + private static ok(kind: DateLikeKind): DateValidation { + return { valid: true, kind } + } + private static err(code: DateErrorCode): DateValidation { + return { valid: false, errorCode: code } + } } diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts index ed37edb6b..400146b81 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts @@ -4,14 +4,8 @@ import { type MetadataField, TypeMetadataFieldOptions } from '../../../../../../../metadata-block-info/domain/models/MetadataBlockInfo' -import { - isValidURL, - isValidFloat, - isValidEmail, - isValidInteger, - isValidDateFormat, - dateKeyMessageErrorMap -} from '../../../../../../../metadata-block-info/domain/models/fieldValidations' +import { Validator } from '@/shared/helpers/Validator' +import { dateKeyMessageErrorMap, MetadataFieldsHelper } from '../../../MetadataFieldsHelper' interface Props { metadataFieldInfo: MetadataField @@ -41,13 +35,13 @@ export const useDefineRules = ({ } if (type === TypeMetadataFieldOptions.URL) { - if (!isValidURL(value)) { + if (!Validator.isValidURL(value)) { return t('field.invalid.url', { displayName, interpolation: { escapeValue: false } }) } return true } if (type === TypeMetadataFieldOptions.Date) { - const validationResult = isValidDateFormat(value) + const validationResult = MetadataFieldsHelper.isValidDateFormat(value) if (!validationResult.valid) { const baseMessage = t('field.invalid.date.base', { @@ -61,19 +55,19 @@ export const useDefineRules = ({ return true } if (type === TypeMetadataFieldOptions.Email) { - if (!isValidEmail(value)) { + if (!Validator.isValidEmail(value)) { return t('field.invalid.email', { displayName, interpolation: { escapeValue: false } }) } return true } if (type === TypeMetadataFieldOptions.Int) { - if (!isValidInteger(value)) { + if (!Validator.isValidNumber(value)) { return t('field.invalid.int', { displayName, interpolation: { escapeValue: false } }) } return true } if (type === TypeMetadataFieldOptions.Float) { - if (!isValidFloat(value)) { + if (!Validator.isValidFloat(value)) { return t('field.invalid.float', { displayName, interpolation: { escapeValue: false } }) } return true diff --git a/src/sections/shared/form/DatasetMetadataForm/useSubmitDataset.ts b/src/sections/shared/form/DatasetMetadataForm/useSubmitDataset.ts index c10f6eafb..da386e67d 100644 --- a/src/sections/shared/form/DatasetMetadataForm/useSubmitDataset.ts +++ b/src/sections/shared/form/DatasetMetadataForm/useSubmitDataset.ts @@ -6,7 +6,6 @@ import { DatasetRepository } from '../../../../dataset/domain/repositories/Datas import { createDataset } from '../../../../dataset/domain/useCases/createDataset' import { updateDatasetMetadata } from '../../../../dataset/domain/useCases/updateDatasetMetadata' import { MetadataFieldsHelper, type DatasetMetadataFormValues } from './MetadataFieldsHelper' -import { getValidationFailedFieldError } from '../../../../metadata-block-info/domain/models/fieldValidations' import { type DatasetMetadataFormMode } from '.' import { QueryParamKey, Route } from '../../../Route.enum' import { DatasetNonNumericVersionSearchParam } from '../../../../dataset/domain/models/Dataset' @@ -74,7 +73,7 @@ export function useSubmitDataset( .catch((err) => { const errorMessage = err instanceof Error && err.message - ? getValidationFailedFieldError(err.message) ?? err.message + ? MetadataFieldsHelper.getValidationFailedFieldError(err.message) ?? err.message : t('validationAlert.content') setSubmitError(errorMessage) @@ -104,7 +103,7 @@ export function useSubmitDataset( .catch((err) => { const errorMessage = err instanceof Error && err.message - ? getValidationFailedFieldError(err.message) ?? err.message + ? MetadataFieldsHelper.getValidationFailedFieldError(err.message) ?? err.message : t('validationAlert.content') setSubmitError(errorMessage) diff --git a/src/shared/helpers/Validator.ts b/src/shared/helpers/Validator.ts index 55e3b9c18..687f25ba7 100644 --- a/src/shared/helpers/Validator.ts +++ b/src/shared/helpers/Validator.ts @@ -1,3 +1,5 @@ +type AcceptedProtocols = 'http' | 'https' | 'ftp' + export class Validator { static isValidEmail(email: string): boolean { const EMAIL_REGEX = @@ -31,4 +33,41 @@ export class Validator { const NUMBER_REGEX = /^\d+$/ return NUMBER_REGEX.test(input) } + + static isValidURL(url: string, specificProtocols?: AcceptedProtocols[]): boolean { + try { + const urlObj = new URL(url) + + const acceptedProtocols: AcceptedProtocols[] = ['http', 'https', 'ftp'] + + if (!acceptedProtocols.includes(urlObj.protocol.slice(0, -1) as AcceptedProtocols)) { + return false + } + + if ( + specificProtocols && + !specificProtocols.includes(urlObj.protocol.slice(0, -1) as AcceptedProtocols) + ) { + return false + } + + if (!this.isValidHostname(urlObj.hostname)) { + return false + } + + return true + } catch (_error) { + return false + } + } + + static isValidHostname(hostname: string): boolean { + const hostnameRegex = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ + return hostnameRegex.test(hostname) + } + + static isValidFloat(value: string): boolean { + const FLOAT_REGEX = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/ + return FLOAT_REGEX.test(value) + } } diff --git a/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx b/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx index a903875c2..348aa53a6 100644 --- a/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx +++ b/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx @@ -14,6 +14,8 @@ const metadataBlockInfoRepository: MetadataBlockInfoRepository = {} as MetadataB const dataset = DatasetMother.createRealistic() const metadataBlocksInfoOnEditMode = MetadataBlockInfoMother.getByCollectionIdDisplayedOnCreateFalse() +const metadataBlocksInfoOnCreateMode = + MetadataBlockInfoMother.getByCollectionIdDisplayedOnCreateTrue() describe('EditDatasetMetadata', () => { const mountWithDataset = (component: ReactNode, dataset: DatasetModel | undefined) => { @@ -21,6 +23,9 @@ describe('EditDatasetMetadata', () => { datasetRepository.getByPersistentId = cy.stub().resolves(dataset) metadataBlockInfoRepository.getByCollectionId = cy.stub().resolves(metadataBlocksInfoOnEditMode) + metadataBlockInfoRepository.getDisplayedOnCreateByCollectionId = cy + .stub() + .resolves(metadataBlocksInfoOnCreateMode) datasetRepository.updateMetadata = cy.stub().resolves(undefined) datasetRepository.getDatasetVersionsSummaries = cy.stub().resolves(undefined) From d1f4fd158c7f6a848b6b9318d8dd05babedc78da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 2 Oct 2025 14:31:48 -0300 Subject: [PATCH 3/8] fix: some more tweaks in validations --- .../MetadataFieldsHelper.ts | 61 ++++++++-------- .../MetadataFieldsHelper.spec.ts | 69 +++++++++++++++++++ 2 files changed, 100 insertions(+), 30 deletions(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index 6b16561f0..cb9abde75 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -32,14 +32,14 @@ type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[] export type ComposedSingleFieldValue = Record -type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP' +export type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP' /** Stable error codes for i18n mapping */ export const dateKeyMessageErrorMap = { E_EMPTY: 'field.invalid.date.empty', E_AD_DIGITS: 'field.invalid.date.adDigits', E_AD_RANGE: 'field.invalid.date.adRange', - E_BC_NOT_NUMERIC: 'field.invalid.date.bcNotNumeric', + E_BC_NOT_NUM: 'field.invalid.date.bcNotNumeric', E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative', E_BRACKET_NOT_NUM: 'field.invalid.date.bracketNotNumeric', E_BRACKET_RANGE: 'field.invalid.date.bracketRange', @@ -49,7 +49,7 @@ export const dateKeyMessageErrorMap = { E_UNRECOGNIZED: 'field.invalid.date.unrecognized' } as const -type DateErrorCode = keyof typeof dateKeyMessageErrorMap +export type DateErrorCode = keyof typeof dateKeyMessageErrorMap type DateValidation = | { valid: true; kind: DateLikeKind } @@ -713,16 +713,14 @@ export class MetadataFieldsHelper { .replace(/\s+/g, ' ') if (s.includes('BC')) { + // BC: must be purely numeric if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM') - return this.ok('BRACKET') } else { + // AD/unspecified: numeric, 1–4 digits, 0..9999 if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM') - if (!/^\d{1,4}$/.test(core)) return this.err('E_BRACKET_RANGE') - if (!this.isValidDateAgainstPattern(core, 'yyyy')) return this.err('E_BRACKET_RANGE') - return this.ok('BRACKET') } } @@ -733,8 +731,6 @@ export class MetadataFieldsHelper { if (!/^\d+$/.test(before)) return this.err('E_AD_DIGITS') - if (before.length > 4 || Number(before) > 9999) return this.err('E_AD_RANGE') - if (!this.isValidDateAgainstPattern(before, 'yyyy')) return this.err('E_AD_RANGE') return this.ok('AD') @@ -743,19 +739,38 @@ export class MetadataFieldsHelper { // 4) BC: strip BC, numeric only (no explicit max in JSF) if (s.includes('BC')) { const before = s.substring(0, s.indexOf('BC')).trim() - - if (!/^\d+$/.test(before)) return this.err('E_BC_NOT_NUMERIC') - + if (!/^\d+$/.test(before)) return this.err('E_BC_NOT_NUM') return this.ok('BC') } + // 5) Timestamp fallbacks (temporary) if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return this.ok('TIMESTAMP') if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return this.ok('TIMESTAMP') if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return this.ok('TIMESTAMP') + // ---------- Targeted error mapping (ORDER MATTERS) ---------- + + // Numeric but longer than 4 → AD range-style error if (/^\d+$/.test(s) && s.length > 4) return this.err('E_AD_RANGE') + + // Distinguish month vs day precisely + const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) + if (ymd) { + const [, yStr, mStr, dStr] = ymd + const year = Number(yStr) + const month = Number(mStr) + const day = Number(dStr) + + if (month < 1 || month > 12) return this.err('E_INVALID_MONTH') + const dim = this.daysInMonth(year, month) + if (day < 1 || day > dim) return this.err('E_INVALID_DAY') + // If we get here, something else was invalid earlier, fall through. + } + + // Pure year-month (yyyy-MM) → invalid month if (/^\d{4}-(\d{2})$/.test(s)) return this.err('E_INVALID_MONTH') - if (/^\d{4}-(\d{2})-(\d{2})$/.test(s)) return this.err('E_INVALID_DAY') + + // Looks like datetime → invalid time if (/^\d{4}-\d{2}-\d{2}T/.test(s) || /^\d{4}-\d{2}-\d{2} /.test(s)) return this.err('E_INVALID_TIME') @@ -765,20 +780,18 @@ export class MetadataFieldsHelper { public static isValidDateAgainstPattern(dateString: string, pattern: string): boolean { if (!dateString) return false const s = dateString.trim() - if (s.length > pattern.length) return false + if (s.length > pattern.length) return false // mirrors Java length guard switch (pattern) { case 'yyyy': { if (!/^\d{1,4}$/.test(s)) return false const year = Number(s) - return year >= 0 && year <= 9999 + return year >= 0 && year <= 9999 // AD cap } case 'yyyy-MM': { const m = /^(\d{1,4})-(\d{2})$/.exec(s) if (!m) return false - const year = Number(m[1]) const month = Number(m[2]) - if (!(year >= 0 && year <= 9999)) return false return month >= 1 && month <= 12 } case 'yyyy-MM-dd': { @@ -787,42 +800,30 @@ export class MetadataFieldsHelper { const year = Number(m[1]) const month = Number(m[2]) const day = Number(m[3]) - if (!(year >= 0 && year <= 9999)) return false + // if (!(year >= 0 && year <= 9999)) return false if (!(month >= 1 && month <= 12)) return false const dim = this.daysInMonth(year, month) return day >= 1 && day <= dim } case "yyyy-MM-dd'T'HH:mm:ss": { const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(s) - if (!m) return false - const [, y, mo, d, hh, mm, ss] = m - if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false - return this.isValidHMS(hh, mm, ss) } case "yyyy-MM-dd'T'HH:mm:ss.SSS": { const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s) - if (!m) return false - const [, y, mo, d, hh, mm, ss, ms] = m - if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false - return this.isValidHMS(hh, mm, ss) && /^\d{3}$/.test(ms) } case 'yyyy-MM-dd HH:mm:ss': { const m = /^(\d{1,4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(s) - if (!m) return false - const [, y, mo, d, hh, mm, ss] = m - if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false - return this.isValidHMS(hh, mm, ss) } default: 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 91103062e..ce97b27e0 100644 --- a/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts +++ b/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts @@ -6,6 +6,8 @@ import { } from '../../../../../src/metadata-block-info/domain/models/MetadataBlockInfo' import { DatasetMetadataFormValues, + DateErrorCode, + DateLikeKind, MetadataFieldsHelper } from '../../../../../src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper' import { defaultLicense } from '../../../../../src/dataset/domain/models/Dataset' @@ -1532,4 +1534,71 @@ describe('MetadataFieldsHelper', () => { ) }) }) + + describe('isValidDateFormat', () => { + const validCases: { input: string; kind: DateLikeKind }[] = [ + { input: '20', kind: 'Y' }, + { input: '2023', kind: 'Y' }, + { input: '2023-11', kind: 'YM' }, + { input: '2023-11-30', kind: 'YMD' }, + { input: '2020-02-29', kind: 'YMD' }, // leap day + { input: '2023 AD', kind: 'AD' }, + { input: '2023AD', kind: 'AD' }, + { input: '2023 BC', kind: 'BC' }, + { input: '2023BC', kind: 'BC' }, + { input: '123456 BC', kind: 'BC' }, + { input: '[2023?]', kind: 'BRACKET' }, + { input: '[2023 BC?]', kind: 'BRACKET' }, + { input: '2023-11-30T23:59:59', kind: 'TIMESTAMP' }, + { input: '2023-11-30T23:59:59.123', kind: 'TIMESTAMP' }, + { input: '2023-11-30 23:59:59', kind: 'TIMESTAMP' }, + // whitespace trimming + { input: ' 2023-11-30 ', kind: 'YMD' } + ] + + const invalidCases: { input: string; code: DateErrorCode }[] = [ + { input: ' ', code: 'E_UNRECOGNIZED' }, + { input: '', code: 'E_EMPTY' }, + { input: '10000', code: 'E_AD_RANGE' }, + { input: '2023-13', code: 'E_INVALID_MONTH' }, + { input: '2023-00', code: 'E_INVALID_MONTH' }, + { input: '2023-11-31', code: 'E_INVALID_DAY' }, + { input: '2019-02-29', code: 'E_INVALID_DAY' }, // not leap year + { input: '2023-11-30T25:00:00', code: 'E_INVALID_TIME' }, + { input: '2023-11-30T23:59:60', code: 'E_INVALID_TIME' }, + { input: '[-2023?]', code: 'E_BRACKET_NEGATIVE' }, + { input: '[2023-11?]', code: 'E_BRACKET_NOT_NUM' }, + { input: '[foo BC?]', code: 'E_BRACKET_NOT_NUM' }, + { input: '[99230 AD?]', code: 'E_BRACKET_RANGE' }, + { input: '[10000?]', code: 'E_BRACKET_RANGE' }, + { input: '[20a3?]', code: 'E_BRACKET_NOT_NUM' }, + { input: 'abcd AD', code: 'E_AD_DIGITS' }, + { input: '20a AD', code: 'E_AD_DIGITS' }, + { input: '12345 AD', code: 'E_AD_RANGE' }, + { input: '12x BC', code: 'E_BC_NOT_NUM' }, + { input: '2023-11-30foo', code: 'E_UNRECOGNIZED' } + ] + + it('accepts all valid inputs and returns the right kind', () => { + validCases.forEach(({ input, kind }) => { + const res = MetadataFieldsHelper.isValidDateFormat(input) + expect(res.valid, `expected valid for "${input}"`).to.eq(true) + + if (res.valid) { + expect(res.kind, `kind for "${input}"`).to.eq(kind) + } + }) + }) + + it('rejects invalid inputs and returns correct error code', () => { + invalidCases.forEach(({ input, code }) => { + const res = MetadataFieldsHelper.isValidDateFormat(input) + expect(res.valid, `expected invalid for "${input}"`).to.eq(false) + + if (!res.valid) { + expect(res.errorCode, `code for "${input}"`).to.eq(code) + } + }) + }) + }) }) From 36db565f7cfc5625f7dd23aa17ed94f82b278294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 2 Oct 2025 14:38:46 -0300 Subject: [PATCH 4/8] test: add more test cases --- .../MetadataFieldsHelper.ts | 10 +++- .../MetadataFieldsHelper.spec.ts | 57 +++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index cb9abde75..38baa428b 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -693,6 +693,12 @@ export class MetadataFieldsHelper { return null } + /** + * This is for validating date type fields in the edit/create dataset form. + * It replicates as much as possible the validation in the Dataverse backend + * https://github.com/IQSS/dataverse/blob/42a2904a83fa3ed990c13813b9bc2bec166bfd4b/src/main/java/edu/harvard/iq/dataverse/DatasetFieldValueValidator.java#L99 + */ + public static isValidDateFormat(input: string): DateValidation { if (!input) return this.err('E_EMPTY') @@ -780,7 +786,7 @@ export class MetadataFieldsHelper { public static isValidDateAgainstPattern(dateString: string, pattern: string): boolean { if (!dateString) return false const s = dateString.trim() - if (s.length > pattern.length) return false // mirrors Java length guard + if (s.length > pattern.length) return false switch (pattern) { case 'yyyy': { @@ -800,7 +806,7 @@ export class MetadataFieldsHelper { const year = Number(m[1]) const month = Number(m[2]) const day = Number(m[3]) - // if (!(year >= 0 && year <= 9999)) return false + if (!(month >= 1 && month <= 12)) return false const dim = this.daysInMonth(year, month) return day >= 1 && day <= dim 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 ce97b27e0..2a842ae6e 100644 --- a/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts +++ b/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts @@ -1537,45 +1537,92 @@ describe('MetadataFieldsHelper', () => { describe('isValidDateFormat', () => { const validCases: { input: string; kind: DateLikeKind }[] = [ + // yyyy, yyyy-MM, yyyy-MM-dd + { input: '1', kind: 'Y' }, { input: '20', kind: 'Y' }, + { input: '999', kind: 'Y' }, { input: '2023', kind: 'Y' }, + { input: ' 2023 ', kind: 'Y' }, // trims { input: '2023-11', kind: 'YM' }, + { input: '1999-01', kind: 'YM' }, + { input: ' 2023-12 ', kind: 'YM' }, // trims { input: '2023-11-30', kind: 'YMD' }, - { input: '2020-02-29', kind: 'YMD' }, // leap day + { input: '2020-02-29', kind: 'YMD' }, // leap year + { input: ' 2023-01-01 ', kind: 'YMD' }, // trims + { input: '2023-11-01', kind: 'YMD' }, // prioritizes YMD over YM/Y + + // AD/BC { input: '2023 AD', kind: 'AD' }, { input: '2023AD', kind: 'AD' }, { input: '2023 BC', kind: 'BC' }, { input: '2023BC', kind: 'BC' }, - { input: '123456 BC', kind: 'BC' }, + { input: '123456 BC', kind: 'BC' }, // BC has no explicit upper bound + + // Bracketed { input: '[2023?]', kind: 'BRACKET' }, { input: '[2023 BC?]', kind: 'BRACKET' }, + + // Timestamps { input: '2023-11-30T23:59:59', kind: 'TIMESTAMP' }, { input: '2023-11-30T23:59:59.123', kind: 'TIMESTAMP' }, { input: '2023-11-30 23:59:59', kind: 'TIMESTAMP' }, - // whitespace trimming - { input: ' 2023-11-30 ', kind: 'YMD' } + { input: ' 2023-11-30 ', kind: 'YMD' } // whitespace trimming ] const invalidCases: { input: string; code: DateErrorCode }[] = [ - { input: ' ', code: 'E_UNRECOGNIZED' }, + // empty / whitespace { input: '', code: 'E_EMPTY' }, + { input: ' ', code: 'E_UNRECOGNIZED' }, + + // year out of range / malformed { input: '10000', code: 'E_AD_RANGE' }, + { input: '2023x', code: 'E_UNRECOGNIZED' }, + { input: 'abcd', code: 'E_UNRECOGNIZED' }, + + // invalid YM { input: '2023-13', code: 'E_INVALID_MONTH' }, { input: '2023-00', code: 'E_INVALID_MONTH' }, + { input: '2023-1', code: 'E_UNRECOGNIZED' }, + { input: '2023-11x', code: 'E_UNRECOGNIZED' }, + + // invalid YMD { input: '2023-11-31', code: 'E_INVALID_DAY' }, { input: '2019-02-29', code: 'E_INVALID_DAY' }, // not leap year + { input: '2023-13-01', code: 'E_INVALID_MONTH' }, + { input: '2023-00-10', code: 'E_INVALID_MONTH' }, + { input: '2023-11-30x', code: 'E_UNRECOGNIZED' }, + + // timestamp invalid date part + { input: '2023-13-01T01:02:03', code: 'E_INVALID_TIME' }, + { input: '2023-11-31T01:02:03', code: 'E_INVALID_TIME' }, + { input: '2019-02-29T01:02:03', code: 'E_INVALID_TIME' }, + { input: '2023-00-10T12:34:56.123', code: 'E_INVALID_TIME' }, + { input: '2023-04-31T12:34:56.123', code: 'E_INVALID_TIME' }, + + // timestamp invalid time part { input: '2023-11-30T25:00:00', code: 'E_INVALID_TIME' }, { input: '2023-11-30T23:59:60', code: 'E_INVALID_TIME' }, + { input: '2023-11-30T12:34:56.12', code: 'E_INVALID_TIME' }, // bad ms + + // timestamp with space + { input: '2023-13-01 00:00:00', code: 'E_INVALID_TIME' }, + { input: '2023-02-30 00:00:00', code: 'E_INVALID_TIME' }, + + // bracketed errors { input: '[-2023?]', code: 'E_BRACKET_NEGATIVE' }, { input: '[2023-11?]', code: 'E_BRACKET_NOT_NUM' }, { input: '[foo BC?]', code: 'E_BRACKET_NOT_NUM' }, { input: '[99230 AD?]', code: 'E_BRACKET_RANGE' }, { input: '[10000?]', code: 'E_BRACKET_RANGE' }, { input: '[20a3?]', code: 'E_BRACKET_NOT_NUM' }, + + // AD/BC malformed { input: 'abcd AD', code: 'E_AD_DIGITS' }, { input: '20a AD', code: 'E_AD_DIGITS' }, { input: '12345 AD', code: 'E_AD_RANGE' }, { input: '12x BC', code: 'E_BC_NOT_NUM' }, + + // trailing junk { input: '2023-11-30foo', code: 'E_UNRECOGNIZED' } ] From 3908c22c1989d25ab06846be9f80ea41b331a8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 2 Oct 2025 15:16:08 -0300 Subject: [PATCH 5/8] feat: add current value entered by the user on error message in email and url field --- public/locales/en/shared.json | 8 ++++---- .../MetadataFormField/useDefineRules.ts | 12 ++++++++++-- .../DatasetMetadataForm.spec.tsx | 18 ++++++++++-------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index 4a23413bc..a5e30fd08 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -62,10 +62,10 @@ "field": { "required": "{{displayName}} is required", "invalid": { - "url": "{{displayName}} is not a valid URL", - "email": "{{displayName}} is not a valid email", - "int": "{{displayName}} is not a valid integer", - "float": "{{displayName}} is not a valid float", + "url": "{{displayName}} {{value}} is not a valid URL.", + "email": "{{displayName}} {{value}} is not a valid email.", + "int": "{{displayName}} is not a valid integer.", + "float": "{{displayName}} is not a valid float.", "date": { "base": "{{displayName}} is not a valid date.", "empty": "The date is empty.", diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts index 400146b81..e555c9b20 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts @@ -36,7 +36,11 @@ export const useDefineRules = ({ if (type === TypeMetadataFieldOptions.URL) { if (!Validator.isValidURL(value)) { - return t('field.invalid.url', { displayName, interpolation: { escapeValue: false } }) + return t('field.invalid.url', { + displayName, + value, + interpolation: { escapeValue: false } + }) } return true } @@ -56,7 +60,11 @@ export const useDefineRules = ({ } if (type === TypeMetadataFieldOptions.Email) { if (!Validator.isValidEmail(value)) { - return t('field.invalid.email', { displayName, interpolation: { escapeValue: false } }) + return t('field.invalid.email', { + displayName, + value, + interpolation: { escapeValue: false } + }) } return true } 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 b1b6c0ac9..7a0f14b02 100644 --- a/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx +++ b/tests/component/sections/shared/dataset-metadata-form/DatasetMetadataForm.spec.tsx @@ -1350,7 +1350,7 @@ describe('DatasetMetadataForm', () => { .within(() => { cy.findByLabelText('Term URI', { exact: true }).type('html://test.com') - cy.findByText('Keyword Term URI is not a valid URL').should('exist') + cy.findByText('Keyword Term URI html://test.com is not a valid URL.').should('exist') }) cy.findByText('Description') @@ -1366,7 +1366,7 @@ describe('DatasetMetadataForm', () => { .closest('.row') .within(() => { cy.findByLabelText(/^E-mail/i).type('test') - cy.findByText('Point of Contact E-mail is not a valid email').should('exist') + cy.findByText('Point of Contact E-mail test is not a valid email.').should('exist') }) }) @@ -1380,13 +1380,13 @@ describe('DatasetMetadataForm', () => { cy.findByLabelText(/Object Density/) .should('exist') .type('30L') - cy.findByText('Object Density is not a valid float').should('exist') + cy.findByText('Object Density is not a valid float.').should('exist') cy.findByText('Object Count').should('exist') cy.findByLabelText(/Object Count/) .should('exist') .type('30.5') - cy.findByText('Object Count is not a valid integer').should('exist') + cy.findByText('Object Count is not a valid integer.').should('exist') }) }) @@ -1411,7 +1411,7 @@ describe('DatasetMetadataForm', () => { .within(() => { cy.findByLabelText('Term URI', { exact: true }).type('http://test.com') - cy.findByText('Keyword Term URI is not a valid URL').should('not.exist') + cy.findByText('Keyword Term URI http://test.com is not a valid URL.').should('not.exist') }) cy.findByText('Description') @@ -1427,7 +1427,9 @@ describe('DatasetMetadataForm', () => { .closest('.row') .within(() => { cy.findByLabelText(/^E-mail/i).type('email@valid.com') - cy.findByText('Point of Contact E-mail is not a valid email').should('not.exist') + cy.findByText('Point of Contact E-mail email@valid.com is not a valid email.').should( + 'not.exist' + ) }) }) @@ -1441,13 +1443,13 @@ describe('DatasetMetadataForm', () => { cy.findByLabelText(/Object Density/) .should('exist') .type('30.5') - cy.findByText('Object Density is not a valid float').should('not.exist') + cy.findByText('Object Density is not a valid float.').should('not.exist') cy.findByText('Object Count').should('exist') cy.findByLabelText(/Object Count/) .should('exist') .type('30') - cy.findByText('Object Count is not a valid integer').should('not.exist') + cy.findByText('Object Count is not a valid integer.').should('not.exist') cy.findByText('Some Date').should('exist') cy.findByLabelText(/Some Date/) From 3ccda5bb60d29d7ef6b6332e31ae283598f1f633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 3 Oct 2025 09:58:26 -0300 Subject: [PATCH 6/8] feat: some more tweaks to match JSF validation --- CHANGELOG.md | 2 ++ package-lock.json | 14 +++++++------- package.json | 2 +- .../DatasetMetadataForm/MetadataFieldsHelper.ts | 4 ++-- .../MetadataFieldsHelper.spec.ts | 10 +++++++--- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac39a8d4..3f8586068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,12 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Dataset Templates Selector in the Create Dataset page. - Metadata Export Dropdown to the metadata tab of Dataset Page and File Page. - External Tools integration. All types supported: Explore, Configure, Preview and Query tools in Dataset and File pages. Still not showing external tools for Auxiliary Files as additional development is needed. +- Added the value entered by the user in the error messages for metadata field validation errors in EMAIL and URL type fields. For example, instead of showing "Point of Contact E-mail is not a valid email address.", we now show "Point of Contact E-mail foo is not a valid email address." ### Changed - Standardize Node.js version to 22 across all environments (docker dev environment, CI, production). +- Changed the way we were handling DATE type metadata field validation to better match the backend validation and give users better error messages. For example, for an input like "foo AD", we now show "Production Date is not a valid date. The AD year must be numeric.". For an input like "99999 AD", we now show "Production Date is not a valid date. The AD year cant be higher than 9999.". For an input like "[-9999?], we now show "Production Date is not a valid date. The year in brackets cannot be negative.", etc. ### Fixed diff --git a/package-lock.json b/package-lock.json index 543455944..724499426 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.67", + "@iqss/dataverse-client-javascript": "2.1.0-pr385.bb9affc", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -81,7 +81,7 @@ "babel-plugin-named-exports-order": "0.0.2", "chai": "4.3.7", "chai-as-promised": "7.1.1", - "chromatic": "^13.3.0", + "chromatic": "13.3.0", "concurrently": "8.0.1", "cypress": "15.2.0", "cypress-vite": "1.4.0", @@ -1954,14 +1954,14 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.67", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.67/0ee4f1c8e03eb3ef688d6b034e84003c9e155d3b", - "integrity": "sha512-uEAGtwXz7LYkBfWCBRktgb5d8oba6yPH9YWnVFhI40UqgdB1sQ/WWCDhZTn5LFsaQY+1XBzOoTjwrQ2HGz94og==", + "version": "2.1.0-pr385.bb9affc", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.1.0-pr385.bb9affc/95a87d5368ca8e12c63d34b43c86d4440e08ee6d", + "integrity": "sha512-iZK3c1aJGKZ/p4Jm8tXS3vg5RaAj8u1yV0ILNqVNNHIZubarG6P/JatV5oNa6q/cNT8Ny2xgsgOqCbbiKRSipA==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", "@types/turndown": "^5.0.1", - "axios": "^1.7.2", + "axios": "^1.12.2", "turndown": "^7.1.2", "typescript": "^4.9.5" } @@ -30401,7 +30401,7 @@ "@testing-library/cypress": "10.1.0", "@vitejs/plugin-react": "4.3.1", "axe-playwright": "1.2.3", - "chromatic": "^13.3.0", + "chromatic": "13.3.0", "cypress": "15.2.0", "react": "18.2.0", "vite": "5.4.20", diff --git a/package.json b/package.json index 34008bc0b..fff77ef51 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,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.67", + "@iqss/dataverse-client-javascript": "2.1.0-pr385.bb9affc", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index 38baa428b..627b2dae8 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -795,13 +795,13 @@ export class MetadataFieldsHelper { return year >= 0 && year <= 9999 // AD cap } case 'yyyy-MM': { - const m = /^(\d{1,4})-(\d{2})$/.exec(s) + const m = /^(\d{1,4})-(\d{1,2})$/.exec(s) if (!m) return false const month = Number(m[2]) return month >= 1 && month <= 12 } case 'yyyy-MM-dd': { - const m = /^(\d{1,4})-(\d{2})-(\d{2})$/.exec(s) + const m = /^(\d{1,4})-(\d{1,2})-(\d{1,2})$/.exec(s) if (!m) return false const year = Number(m[1]) const month = Number(m[2]) 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 2a842ae6e..feea4a3a0 100644 --- a/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts +++ b/tests/component/sections/shared/dataset-metadata-form/MetadataFieldsHelper.spec.ts @@ -1550,6 +1550,12 @@ describe('MetadataFieldsHelper', () => { { input: '2020-02-29', kind: 'YMD' }, // leap year { input: ' 2023-01-01 ', kind: 'YMD' }, // trims { input: '2023-11-01', kind: 'YMD' }, // prioritizes YMD over YM/Y + { input: '2023-1', kind: 'YM' }, + { input: '20-1', kind: 'YM' }, + { input: '2-1', kind: 'YM' }, + { input: '2023-1-5', kind: 'YMD' }, + { input: '2023-12-5', kind: 'YMD' }, + { input: '2-1-5', kind: 'YMD' }, // AD/BC { input: '2023 AD', kind: 'AD' }, @@ -1565,8 +1571,7 @@ describe('MetadataFieldsHelper', () => { // Timestamps { input: '2023-11-30T23:59:59', kind: 'TIMESTAMP' }, { input: '2023-11-30T23:59:59.123', kind: 'TIMESTAMP' }, - { input: '2023-11-30 23:59:59', kind: 'TIMESTAMP' }, - { input: ' 2023-11-30 ', kind: 'YMD' } // whitespace trimming + { input: '2023-11-30 23:59:59', kind: 'TIMESTAMP' } ] const invalidCases: { input: string; code: DateErrorCode }[] = [ @@ -1582,7 +1587,6 @@ describe('MetadataFieldsHelper', () => { // invalid YM { input: '2023-13', code: 'E_INVALID_MONTH' }, { input: '2023-00', code: 'E_INVALID_MONTH' }, - { input: '2023-1', code: 'E_UNRECOGNIZED' }, { input: '2023-11x', code: 'E_UNRECOGNIZED' }, // invalid YMD From f213b761efb751d124151385cb16837a5fe2e196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 6 Oct 2025 08:21:33 -0300 Subject: [PATCH 7/8] fix: copilot reviews --- public/locales/en/shared.json | 2 +- .../shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index a5e30fd08..d4bc350f9 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -70,7 +70,7 @@ "base": "{{displayName}} is not a valid date.", "empty": "The date is empty.", "adDigits": "The AD year must be numeric.", - "adRange": "The AD year cant be higher than 9999.", + "adRange": "The AD year can't be higher than 9999.", "bracketNotNumeric": "The year in brackets must be numeric.", "bracketNegative": "The year in brackets cannot be negative.", "bracketRange": "The year in brackets is out of range.", diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts index 627b2dae8..488e977d5 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts @@ -672,7 +672,7 @@ export class MetadataFieldsHelper { * @returns the invalid value or null if it can't be extracted * @example * getValidationFailedFieldError("Validation Failed: Point of Contact E-mail test@test.c is not a valid email address. (Invalid value:edu.harvard.iq.dataverse.DatasetFieldValueValue[ id=null ]).java.util.stream.ReferencePipeline$3@561b5200") - * // returns "Point of Contact E-mail test@test.c is not a valid email address." + * // returns "Point of Contact E-mail test@test.c is not a valid email address." */ public static getValidationFailedFieldError(errorMessage: string): string | null { From 0f2ecd2b1941cac35095f1e29cc7280e62a25b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 9 Oct 2025 11:22:37 -0300 Subject: [PATCH 8/8] docs: fix changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3356275b2..d04c6e244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Added the value entered by the user in the error messages for metadata field validation errors in EMAIL and URL type fields. For example, instead of showing “Point of Contact E-mail is not a valid email address.“, we now show “Point of Contact E-mail foo is not a valid email address.” + ### Changed - Use of the new `sourceLastUpdateTime` query parameter from update dataset and file metadata endpoints to support optimistic concurrency control during editing operations. See [Edit Dataset Metadata](https://guides.dataverse.org/en/6.8/api/native-api.html#edit-dataset-metadata) and [Updating File Metadata](https://guides.dataverse.org/en/6.8/api/native-api.html#updating-file-metadata) guides for more details. +- Changed the way we were handling DATE type metadata field validation to better match the backend validation and give users better error messages. For example, for an input like “foo AD”, we now show “Production Date is not a valid date. The AD year must be numeric.“. For an input like “99999 AD”, we now show “Production Date is not a valid date. The AD year cant be higher than 9999.“. For an input like “[-9999?], we now show “Production Date is not a valid date. The year in brackets cannot be negative.“, etc. ### Fixed