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 diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index cecdf1252..d4bc350f9 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -62,11 +62,23 @@ "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", - "date": "{{displayName}} is not a valid date. Please use the format {{dateFormat}}" + "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.", + "adDigits": "The AD year must be numeric.", + "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.", + "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 deleted file mode 100644 index 990390c24..000000000 --- a/src/metadata-block-info/domain/models/fieldValidations.ts +++ /dev/null @@ -1,105 +0,0 @@ -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])/ - 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) -} - -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 - * @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 -} 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..488e977d5 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 +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_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', + 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 + +export 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,203 @@ 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 + } + + /** + * 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') + + 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')) { + // 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 (!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 (!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_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') + + // 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') + + 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 // AD cap + } + case 'yyyy-MM': { + 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{1,2})-(\d{1,2})$/.exec(s) + if (!m) return false + const year = Number(m[1]) + const month = Number(m[2]) + const day = Number(m[3]) + + 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 9358c9907..e555c9b20 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/useDefineRules.ts @@ -1,17 +1,11 @@ import { UseControllerProps } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { - DateFormatsOptions, type MetadataField, TypeMetadataFieldOptions } from '../../../../../../../metadata-block-info/domain/models/MetadataBlockInfo' -import { - isValidURL, - isValidFloat, - isValidEmail, - isValidInteger, - isValidDateFormat -} from '../../../../../../../metadata-block-info/domain/models/fieldValidations' +import { Validator } from '@/shared/helpers/Validator' +import { dateKeyMessageErrorMap, MetadataFieldsHelper } from '../../../MetadataFieldsHelper' interface Props { metadataFieldInfo: MetadataField @@ -25,7 +19,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 = @@ -41,38 +35,47 @@ export const useDefineRules = ({ } if (type === TypeMetadataFieldOptions.URL) { - if (!isValidURL(value)) { - return t('field.invalid.url', { displayName, interpolation: { escapeValue: false } }) + if (!Validator.isValidURL(value)) { + return t('field.invalid.url', { + displayName, + value, + interpolation: { escapeValue: false } + }) } return true } if (type === TypeMetadataFieldOptions.Date) { - const acceptedDateFormat = - watermark === 'YYYY-MM-DD' ? DateFormatsOptions.YYYYMMDD : undefined + const validationResult = MetadataFieldsHelper.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 } if (type === TypeMetadataFieldOptions.Email) { - if (!isValidEmail(value)) { - return t('field.invalid.email', { displayName, interpolation: { escapeValue: false } }) + if (!Validator.isValidEmail(value)) { + return t('field.invalid.email', { + displayName, + value, + 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 7a6afbe1d..d0eceeff6 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) 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 220b5026a..fdff26187 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/) 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..feea4a3a0 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,122 @@ 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 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' }, + { input: '2023AD', kind: 'AD' }, + { input: '2023 BC', kind: 'BC' }, + { input: '2023BC', 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' } + ] + + const invalidCases: { input: string; code: DateErrorCode }[] = [ + // 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-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' } + ] + + 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) + } + }) + }) + }) })