Currently, we globally enable both DOM and Node.js types. While this mostly works, it can cause conflicts - particularly with `fetch`. For example, TypeScript may incorrectly allow browser-only properties (like `cache`) and reject valid Node.js ones like `dispatcher`. This PR disables DOM types for server-only packages like payload, ensuring Node-specific typings are applied. This caught a few instances of incorrect fetch usage that were previously masked by overlapping DOM types. This is not a perfect solution - packages that contain both server and client code (like richtext-lexical or next) will still suffer from this issue. However, it's an improvement in cases where we can cleanly separate server and client types, like for the `payload` package which is server-only. ## Use-case This change enables https://github.com/payloadcms/payload/pull/12622 to explore using node-native fetch + `dispatcher`, instead of `node-fetch` + `agent`. Currently, it will incorrectly report that `dispatcher` is not a valid property for node-native fetch
984 lines
25 KiB
TypeScript
984 lines
25 KiB
TypeScript
import Ajv from 'ajv'
|
|
import ObjectIdImport from 'bson-objectid'
|
|
|
|
const ObjectId = (ObjectIdImport.default ||
|
|
ObjectIdImport) as unknown as typeof ObjectIdImport.default
|
|
|
|
import type { TFunction } from '@payloadcms/translations'
|
|
import type { JSONSchema4 } from 'json-schema'
|
|
|
|
import type { RichTextAdapter } from '../admin/types.js'
|
|
import type { CollectionSlug } from '../index.js'
|
|
import type { Where } from '../types/index.js'
|
|
import type {
|
|
ArrayField,
|
|
BlocksField,
|
|
CheckboxField,
|
|
CodeField,
|
|
DateField,
|
|
EmailField,
|
|
JSONField,
|
|
NumberField,
|
|
PointField,
|
|
RadioField,
|
|
RelationshipField,
|
|
RelationshipValue,
|
|
RelationshipValueMany,
|
|
RelationshipValueSingle,
|
|
RichTextField,
|
|
SelectField,
|
|
TextareaField,
|
|
TextField,
|
|
UploadField,
|
|
Validate,
|
|
ValueWithRelation,
|
|
} from './config/types.js'
|
|
|
|
import { isNumber } from '../utilities/isNumber.js'
|
|
import { isValidID } from '../utilities/isValidID.js'
|
|
|
|
export type TextFieldValidation = Validate<string, unknown, unknown, TextField>
|
|
|
|
export type TextFieldManyValidation = Validate<string[], unknown, unknown, TextField>
|
|
|
|
export type TextFieldSingleValidation = Validate<string, unknown, unknown, TextField>
|
|
|
|
export const text: TextFieldValidation = (
|
|
value,
|
|
{
|
|
hasMany,
|
|
maxLength: fieldMaxLength,
|
|
maxRows,
|
|
minLength,
|
|
minRows,
|
|
req: {
|
|
payload: { config },
|
|
t,
|
|
},
|
|
required,
|
|
},
|
|
) => {
|
|
let maxLength!: number
|
|
|
|
if (!required) {
|
|
if (!value) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (hasMany === true) {
|
|
const lengthValidationResult = validateArrayLength(value, { maxRows, minRows, required, t })
|
|
if (typeof lengthValidationResult === 'string') {
|
|
return lengthValidationResult
|
|
}
|
|
}
|
|
|
|
if (typeof config?.defaultMaxTextLength === 'number') {
|
|
maxLength = config.defaultMaxTextLength
|
|
}
|
|
if (typeof fieldMaxLength === 'number') {
|
|
maxLength = fieldMaxLength
|
|
}
|
|
|
|
const stringsToValidate: string[] = Array.isArray(value) ? value : [value!]
|
|
|
|
for (const stringValue of stringsToValidate) {
|
|
const length = stringValue?.length || 0
|
|
|
|
if (typeof maxLength === 'number' && length > maxLength) {
|
|
return t('validation:shorterThanMax', { label: t('general:value'), maxLength, stringValue })
|
|
}
|
|
|
|
if (typeof minLength === 'number' && length < minLength) {
|
|
return t('validation:longerThanMin', { label: t('general:value'), minLength, stringValue })
|
|
}
|
|
}
|
|
|
|
if (required) {
|
|
if (!(typeof value === 'string' || Array.isArray(value)) || value?.length === 0) {
|
|
return t('validation:required')
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type PasswordFieldValidation = Validate<string, unknown, unknown, TextField>
|
|
|
|
export const password: PasswordFieldValidation = (
|
|
value,
|
|
{
|
|
maxLength: fieldMaxLength,
|
|
minLength = 3,
|
|
req: {
|
|
payload: { config },
|
|
t,
|
|
},
|
|
required,
|
|
},
|
|
) => {
|
|
let maxLength!: number
|
|
|
|
if (typeof config?.defaultMaxTextLength === 'number') {
|
|
maxLength = config.defaultMaxTextLength
|
|
}
|
|
if (typeof fieldMaxLength === 'number') {
|
|
maxLength = fieldMaxLength
|
|
}
|
|
|
|
if (value && maxLength && value.length > maxLength) {
|
|
return t('validation:shorterThanMax', { maxLength })
|
|
}
|
|
|
|
if (value && minLength && value.length < minLength) {
|
|
return t('validation:longerThanMin', { minLength })
|
|
}
|
|
|
|
if (required && !value) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type ConfirmPasswordFieldValidation = Validate<
|
|
string,
|
|
unknown,
|
|
{ password: string },
|
|
TextField
|
|
>
|
|
|
|
export const confirmPassword: ConfirmPasswordFieldValidation = (
|
|
value,
|
|
{ req: { t }, required, siblingData },
|
|
) => {
|
|
if (required && !value) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
if (value && value !== siblingData.password) {
|
|
return t('fields:passwordsDoNotMatch')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type EmailFieldValidation = Validate<string, unknown, { username?: string }, EmailField>
|
|
|
|
export const email: EmailFieldValidation = (
|
|
value,
|
|
{
|
|
collectionSlug,
|
|
req: {
|
|
payload: { collections, config },
|
|
t,
|
|
},
|
|
required,
|
|
siblingData,
|
|
},
|
|
) => {
|
|
if (collectionSlug) {
|
|
const collection =
|
|
collections?.[collectionSlug]?.config ??
|
|
config.collections.find(({ slug }) => slug === collectionSlug)! // If this is run on the client, `collections` will be undefined, but `config.collections` will be available
|
|
|
|
if (
|
|
collection.auth.loginWithUsername &&
|
|
!collection.auth.loginWithUsername?.requireUsername &&
|
|
!collection.auth.loginWithUsername?.requireEmail
|
|
) {
|
|
if (!value && !siblingData?.username) {
|
|
return t('validation:required')
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disallows emails with double quotes (e.g., "user"@example.com, user@"example.com", "user@example.com")
|
|
* Rejects spaces anywhere in the email (e.g., user @example.com, user@ example.com, user name@example.com)
|
|
* Prevents consecutive dots in the local or domain part (e.g., user..name@example.com, user@example..com)
|
|
* Disallows domains that start or end with a hyphen (e.g., user@-example.com, user@example-.com)
|
|
* Allows standard email formats (e.g., user@example.com, user.name+alias@example.co.uk, user-name@example.org)
|
|
* Allows domains with consecutive hyphens as long as they are not leading/trailing (e.g., user@ex--ample.com)
|
|
* Supports multiple subdomains (e.g., user@sub.domain.example.com)
|
|
*/
|
|
const emailRegex =
|
|
/^(?!.*\.\.)[\w!#$%&'*+/=?^`{|}~-](?:[\w!#$%&'*+/=?^`{|}~.-]*[\w!#$%&'*+/=?^`{|}~-])?@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i
|
|
|
|
if ((value && !emailRegex.test(value)) || (!value && required)) {
|
|
return t('validation:emailAddress')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type UsernameFieldValidation = Validate<string, unknown, { email?: string }, TextField>
|
|
|
|
export const username: UsernameFieldValidation = (
|
|
value,
|
|
{
|
|
collectionSlug,
|
|
req: {
|
|
payload: { collections, config },
|
|
t,
|
|
},
|
|
required,
|
|
siblingData,
|
|
},
|
|
) => {
|
|
let maxLength!: number
|
|
|
|
if (collectionSlug) {
|
|
const collection =
|
|
collections?.[collectionSlug]?.config ??
|
|
config.collections.find(({ slug }) => slug === collectionSlug)! // If this is run on the client, `collections` will be undefined, but `config.collections` will be available
|
|
|
|
if (
|
|
collection.auth.loginWithUsername &&
|
|
!collection.auth.loginWithUsername?.requireUsername &&
|
|
!collection.auth.loginWithUsername?.requireEmail
|
|
) {
|
|
if (!value && !siblingData?.email) {
|
|
return t('validation:required')
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof config?.defaultMaxTextLength === 'number') {
|
|
maxLength = config.defaultMaxTextLength
|
|
}
|
|
|
|
if (value && maxLength && value.length > maxLength) {
|
|
return t('validation:shorterThanMax', { maxLength })
|
|
}
|
|
|
|
if (!value && required) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type TextareaFieldValidation = Validate<string, unknown, unknown, TextareaField>
|
|
|
|
export const textarea: TextareaFieldValidation = (
|
|
value,
|
|
{
|
|
maxLength: fieldMaxLength,
|
|
minLength,
|
|
req: {
|
|
payload: { config },
|
|
t,
|
|
},
|
|
required,
|
|
},
|
|
) => {
|
|
let maxLength!: number
|
|
|
|
if (typeof config?.defaultMaxTextLength === 'number') {
|
|
maxLength = config.defaultMaxTextLength
|
|
}
|
|
if (typeof fieldMaxLength === 'number') {
|
|
maxLength = fieldMaxLength
|
|
}
|
|
if (value && maxLength && value.length > maxLength) {
|
|
return t('validation:shorterThanMax', { maxLength })
|
|
}
|
|
|
|
if (value && minLength && value.length < minLength) {
|
|
return t('validation:longerThanMin', { minLength })
|
|
}
|
|
|
|
if (required && !value) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type CodeFieldValidation = Validate<string, unknown, unknown, CodeField>
|
|
|
|
export const code: CodeFieldValidation = (value, { req: { t }, required }) => {
|
|
if (required && value === undefined) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type JSONFieldValidation = Validate<
|
|
string,
|
|
unknown,
|
|
unknown,
|
|
{ jsonError?: string } & JSONField
|
|
>
|
|
|
|
export const json: JSONFieldValidation = (
|
|
value,
|
|
{ jsonError, jsonSchema, req: { t }, required },
|
|
) => {
|
|
const isNotEmpty = (value: null | string | undefined) => {
|
|
if (value === undefined || value === null) {
|
|
return false
|
|
}
|
|
|
|
if (Array.isArray(value) && value.length === 0) {
|
|
return false
|
|
}
|
|
|
|
if (typeof value === 'object' && Object.keys(value).length === 0) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const fetchSchema = ({ schema, uri }: { schema: JSONSchema4; uri: string }) => {
|
|
if (uri && schema) {
|
|
return schema
|
|
}
|
|
return fetch(uri)
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error('Network response was not ok')
|
|
}
|
|
return response.json()
|
|
})
|
|
.then((_json) => {
|
|
const json = _json as {
|
|
id: string
|
|
}
|
|
const jsonSchemaSanitizations = {
|
|
id: undefined,
|
|
$id: json.id,
|
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
}
|
|
|
|
return Object.assign(json, jsonSchemaSanitizations)
|
|
})
|
|
}
|
|
|
|
if (required && !value) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
if (jsonError !== undefined) {
|
|
return t('validation:invalidInput')
|
|
}
|
|
|
|
if (jsonSchema && isNotEmpty(value)) {
|
|
try {
|
|
jsonSchema.schema = fetchSchema(jsonSchema)
|
|
const { schema } = jsonSchema
|
|
// @ts-expect-error missing types
|
|
const ajv = new Ajv()
|
|
|
|
if (!ajv.validate(schema, value)) {
|
|
return ajv.errorsText()
|
|
}
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : 'Unknown error'
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
export type CheckboxFieldValidation = Validate<boolean, unknown, unknown, CheckboxField>
|
|
|
|
export const checkbox: CheckboxFieldValidation = (value, { req: { t }, required }) => {
|
|
if ((value && typeof value !== 'boolean') || (required && typeof value !== 'boolean')) {
|
|
return t('validation:trueOrFalse')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type DateFieldValidation = Validate<Date, unknown, unknown, DateField>
|
|
|
|
export const date: DateFieldValidation = (
|
|
value,
|
|
{ name, req: { t }, required, siblingData, timezone },
|
|
) => {
|
|
const validDate = value && !isNaN(Date.parse(value.toString()))
|
|
|
|
// We need to also check for the timezone data based on this field's config
|
|
// We cannot do this inside the timezone field validation as it's visually hidden
|
|
const hasRequiredTimezone = timezone && required
|
|
const selectedTimezone: string = siblingData?.[`${name}_tz` as keyof typeof siblingData]
|
|
// Always resolve to true if the field is not required, as timezone may be optional too then
|
|
const validTimezone = hasRequiredTimezone ? Boolean(selectedTimezone) : true
|
|
|
|
if (validDate && validTimezone) {
|
|
return true
|
|
}
|
|
|
|
if (validDate && !validTimezone) {
|
|
return t('validation:timezoneRequired')
|
|
}
|
|
|
|
if (value) {
|
|
return t('validation:notValidDate', { value })
|
|
}
|
|
|
|
if (required) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type RichTextFieldValidation = Validate<object, unknown, unknown, RichTextField>
|
|
|
|
export const richText: RichTextFieldValidation = async (value, options) => {
|
|
if (!options?.editor) {
|
|
throw new Error('richText field has no editor property.')
|
|
}
|
|
if (typeof options?.editor === 'function') {
|
|
throw new Error('Attempted to access unsanitized rich text editor.')
|
|
}
|
|
|
|
const editor: RichTextAdapter = options?.editor
|
|
|
|
return editor.validate(value, options)
|
|
}
|
|
|
|
const validateArrayLength = (
|
|
value: unknown,
|
|
options: {
|
|
maxRows?: number
|
|
minRows?: number
|
|
required?: boolean
|
|
t: TFunction
|
|
},
|
|
) => {
|
|
const { maxRows, minRows, required, t } = options
|
|
|
|
const arrayLength = Array.isArray(value) ? value.length : (value as number) || 0
|
|
|
|
if (!required && arrayLength === 0) {
|
|
return true
|
|
}
|
|
|
|
if (minRows && arrayLength < minRows) {
|
|
return t('validation:requiresAtLeast', { count: minRows, label: t('general:rows') })
|
|
}
|
|
|
|
if (maxRows && arrayLength > maxRows) {
|
|
return t('validation:requiresNoMoreThan', { count: maxRows, label: t('general:rows') })
|
|
}
|
|
|
|
if (required && !arrayLength) {
|
|
return t('validation:requiresAtLeast', { count: 1, label: t('general:row') })
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type NumberFieldValidation = Validate<number | number[], unknown, unknown, NumberField>
|
|
|
|
export type NumberFieldManyValidation = Validate<number[], unknown, unknown, NumberField>
|
|
|
|
export type NumberFieldSingleValidation = Validate<number, unknown, unknown, NumberField>
|
|
|
|
export const number: NumberFieldValidation = (
|
|
value,
|
|
{ hasMany, max, maxRows, min, minRows, req: { t }, required },
|
|
) => {
|
|
if (hasMany === true) {
|
|
const lengthValidationResult = validateArrayLength(value, { maxRows, minRows, required, t })
|
|
if (typeof lengthValidationResult === 'string') {
|
|
return lengthValidationResult
|
|
}
|
|
}
|
|
|
|
if (!value && !isNumber(value)) {
|
|
// if no value is present, validate based on required
|
|
if (required) {
|
|
return t('validation:required')
|
|
}
|
|
if (!required) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
const numbersToValidate: number[] = Array.isArray(value) ? value : [value!]
|
|
|
|
for (const number of numbersToValidate) {
|
|
if (!isNumber(number)) {
|
|
return t('validation:enterNumber')
|
|
}
|
|
|
|
const numberValue = parseFloat(number as unknown as string)
|
|
|
|
if (typeof max === 'number' && numberValue > max) {
|
|
return t('validation:greaterThanMax', { label: t('general:value'), max, value })
|
|
}
|
|
|
|
if (typeof min === 'number' && numberValue < min) {
|
|
return t('validation:lessThanMin', { label: t('general:value'), min, value })
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type ArrayFieldValidation = Validate<unknown[], unknown, unknown, ArrayField>
|
|
|
|
export const array: ArrayFieldValidation = (value, { maxRows, minRows, req: { t }, required }) => {
|
|
return validateArrayLength(value, { maxRows, minRows, required, t })
|
|
}
|
|
|
|
export type BlocksFieldValidation = Validate<unknown, unknown, unknown, BlocksField>
|
|
|
|
export const blocks: BlocksFieldValidation = (
|
|
value,
|
|
{ maxRows, minRows, req: { t }, required },
|
|
) => {
|
|
return validateArrayLength(value, { maxRows, minRows, required, t })
|
|
}
|
|
|
|
const validateFilterOptions: Validate<
|
|
unknown,
|
|
unknown,
|
|
unknown,
|
|
RelationshipField | UploadField
|
|
> = async (
|
|
value,
|
|
{ id, blockData, data, filterOptions, relationTo, req, req: { t, user }, siblingData },
|
|
) => {
|
|
if (typeof filterOptions !== 'undefined' && value) {
|
|
const options: {
|
|
[collection: string]: (number | string)[]
|
|
} = {}
|
|
|
|
const falseCollections: CollectionSlug[] = []
|
|
const collections = !Array.isArray(relationTo) ? [relationTo] : relationTo
|
|
const values = Array.isArray(value) ? value : [value]
|
|
|
|
for (const collection of collections) {
|
|
try {
|
|
let optionFilter =
|
|
typeof filterOptions === 'function'
|
|
? await filterOptions({
|
|
id: id!,
|
|
blockData,
|
|
data,
|
|
relationTo: collection,
|
|
req,
|
|
siblingData,
|
|
user,
|
|
})
|
|
: filterOptions
|
|
|
|
if (optionFilter === true) {
|
|
optionFilter = null
|
|
}
|
|
|
|
const valueIDs: (number | string)[] = []
|
|
|
|
values.forEach((val) => {
|
|
if (typeof val === 'object') {
|
|
if (val?.value) {
|
|
valueIDs.push(val.value)
|
|
} else if (ObjectId.isValid(val)) {
|
|
valueIDs.push(new ObjectId(val).toHexString())
|
|
}
|
|
}
|
|
|
|
if (typeof val === 'string' || typeof val === 'number') {
|
|
valueIDs.push(val)
|
|
}
|
|
})
|
|
|
|
if (valueIDs.length > 0) {
|
|
const findWhere: Where = {
|
|
and: [{ id: { in: valueIDs } }],
|
|
}
|
|
|
|
// @ts-expect-error - I don't understand why optionFilter is inferred as `false | Where | null` instead of `boolean | Where | null`
|
|
if (optionFilter && optionFilter !== true) {
|
|
findWhere.and?.push(optionFilter)
|
|
}
|
|
|
|
if (optionFilter === false) {
|
|
falseCollections.push(collection)
|
|
}
|
|
|
|
const result = await req.payloadDataLoader.find({
|
|
collection,
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
req,
|
|
where: findWhere,
|
|
})
|
|
|
|
options[collection] = result.docs.map((doc) => doc.id)
|
|
} else {
|
|
options[collection] = []
|
|
}
|
|
} catch (err) {
|
|
req.payload.logger.error({
|
|
err,
|
|
msg: `Error validating filter options for collection ${collection}`,
|
|
})
|
|
options[collection] = []
|
|
}
|
|
}
|
|
|
|
const invalidRelationships = values.filter((val) => {
|
|
let collection: string
|
|
let requestedID: number | string
|
|
|
|
if (typeof relationTo === 'string') {
|
|
collection = relationTo
|
|
|
|
if (typeof val === 'string' || typeof val === 'number') {
|
|
requestedID = val
|
|
}
|
|
|
|
if (typeof val === 'object' && ObjectId.isValid(val)) {
|
|
requestedID = new ObjectId(val).toHexString()
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) {
|
|
collection = val.relationTo
|
|
requestedID = val.value
|
|
}
|
|
|
|
if (falseCollections.find((slug) => relationTo === slug)) {
|
|
return true
|
|
}
|
|
|
|
if (!options[collection!]) {
|
|
return true
|
|
}
|
|
|
|
return options[collection!]!.indexOf(requestedID!) === -1
|
|
})
|
|
|
|
if (invalidRelationships.length > 0) {
|
|
return invalidRelationships.reduce((err, invalid, i) => {
|
|
return `${err} ${JSON.stringify(invalid)}${
|
|
invalidRelationships.length === i + 1 ? ',' : ''
|
|
} `
|
|
}, t('validation:invalidSelections')) as string
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type UploadFieldValidation = Validate<unknown, unknown, unknown, UploadField>
|
|
|
|
export type UploadFieldManyValidation = Validate<unknown[], unknown, unknown, UploadField>
|
|
|
|
export type UploadFieldSingleValidation = Validate<unknown, unknown, unknown, UploadField>
|
|
|
|
export const upload: UploadFieldValidation = async (value, options) => {
|
|
const {
|
|
event,
|
|
maxRows,
|
|
minRows,
|
|
relationTo,
|
|
req: { payload, t },
|
|
required,
|
|
} = options
|
|
|
|
if (
|
|
((!value && typeof value !== 'number') || (Array.isArray(value) && value.length === 0)) &&
|
|
required
|
|
) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
if (minRows && value.length < minRows) {
|
|
return t('validation:lessThanMin', {
|
|
label: t('general:rows'),
|
|
min: minRows,
|
|
value: value.length,
|
|
})
|
|
}
|
|
|
|
if (maxRows && value.length > maxRows) {
|
|
return t('validation:greaterThanMax', {
|
|
label: t('general:rows'),
|
|
max: maxRows,
|
|
value: value.length,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (typeof value !== 'undefined' && value !== null) {
|
|
const values = Array.isArray(value) ? value : [value]
|
|
|
|
const invalidRelationships = values.filter((val) => {
|
|
let collectionSlug: string
|
|
let requestedID
|
|
|
|
if (typeof relationTo === 'string') {
|
|
collectionSlug = relationTo
|
|
|
|
// custom id
|
|
if (val || typeof val === 'number') {
|
|
requestedID = val
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) {
|
|
collectionSlug = val.relationTo
|
|
requestedID = val.value
|
|
}
|
|
|
|
if (requestedID === null) {
|
|
return false
|
|
}
|
|
|
|
const idType =
|
|
payload.collections[collectionSlug!]?.customIDType || payload?.db?.defaultIDType || 'text'
|
|
|
|
return !isValidID(requestedID, idType)
|
|
})
|
|
|
|
if (invalidRelationships.length > 0) {
|
|
return `This relationship field has the following invalid relationships: ${invalidRelationships
|
|
.map((err, invalid) => {
|
|
return `${err} ${JSON.stringify(invalid)}`
|
|
})
|
|
.join(', ')}`
|
|
}
|
|
}
|
|
|
|
if (event === 'onChange') {
|
|
return true
|
|
}
|
|
|
|
return validateFilterOptions(value, options)
|
|
}
|
|
|
|
export type RelationshipFieldValidation = Validate<
|
|
RelationshipValue,
|
|
unknown,
|
|
unknown,
|
|
RelationshipField
|
|
>
|
|
|
|
export type RelationshipFieldManyValidation = Validate<
|
|
RelationshipValueMany,
|
|
unknown,
|
|
unknown,
|
|
RelationshipField
|
|
>
|
|
|
|
export type RelationshipFieldSingleValidation = Validate<
|
|
RelationshipValueSingle,
|
|
unknown,
|
|
unknown,
|
|
RelationshipField
|
|
>
|
|
|
|
export const relationship: RelationshipFieldValidation = async (value, options) => {
|
|
const {
|
|
event,
|
|
maxRows,
|
|
minRows,
|
|
relationTo,
|
|
req: { payload, t },
|
|
required,
|
|
} = options
|
|
|
|
if (
|
|
((!value && typeof value !== 'number') || (Array.isArray(value) && value.length === 0)) &&
|
|
required
|
|
) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
if (minRows && value.length < minRows) {
|
|
return t('validation:lessThanMin', {
|
|
label: t('general:rows'),
|
|
min: minRows,
|
|
value: value.length,
|
|
})
|
|
}
|
|
|
|
if (maxRows && value.length > maxRows) {
|
|
return t('validation:greaterThanMax', {
|
|
label: t('general:rows'),
|
|
max: maxRows,
|
|
value: value.length,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (typeof value !== 'undefined' && value !== null) {
|
|
const values = Array.isArray(value) ? value : [value]
|
|
|
|
const invalidRelationships = values.filter((val) => {
|
|
let collectionSlug: string
|
|
let requestedID: number | string | undefined | ValueWithRelation
|
|
|
|
if (typeof relationTo === 'string') {
|
|
collectionSlug = relationTo
|
|
|
|
// custom id
|
|
if (val || typeof val === 'number') {
|
|
requestedID = val
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) {
|
|
collectionSlug = val.relationTo
|
|
requestedID = val.value
|
|
}
|
|
|
|
if (requestedID === null) {
|
|
return false
|
|
}
|
|
|
|
const idType =
|
|
payload.collections[collectionSlug!]?.customIDType || payload?.db?.defaultIDType || 'text'
|
|
|
|
return !isValidID(requestedID as number | string, idType)
|
|
})
|
|
|
|
if (invalidRelationships.length > 0) {
|
|
return `This relationship field has the following invalid relationships: ${invalidRelationships
|
|
.map((err, invalid) => {
|
|
return `${err} ${JSON.stringify(invalid)}`
|
|
})
|
|
.join(', ')}`
|
|
}
|
|
}
|
|
|
|
if (event === 'onChange') {
|
|
return true
|
|
}
|
|
|
|
return validateFilterOptions(value, options)
|
|
}
|
|
|
|
export type SelectFieldValidation = Validate<string | string[], unknown, unknown, SelectField>
|
|
|
|
export type SelectFieldManyValidation = Validate<string[], unknown, unknown, SelectField>
|
|
|
|
export type SelectFieldSingleValidation = Validate<string, unknown, unknown, SelectField>
|
|
|
|
export const select: SelectFieldValidation = (
|
|
value,
|
|
{ data, filterOptions, hasMany, options, req, req: { t }, required, siblingData },
|
|
) => {
|
|
const filteredOptions =
|
|
typeof filterOptions === 'function'
|
|
? filterOptions({
|
|
data,
|
|
options,
|
|
req,
|
|
siblingData,
|
|
})
|
|
: options
|
|
|
|
if (
|
|
Array.isArray(value) &&
|
|
value.some(
|
|
(input) =>
|
|
!filteredOptions.some(
|
|
(option) => option === input || (typeof option !== 'string' && option?.value === input),
|
|
),
|
|
)
|
|
) {
|
|
return t('validation:invalidSelection')
|
|
}
|
|
|
|
if (
|
|
typeof value === 'string' &&
|
|
!filteredOptions.some(
|
|
(option) => option === value || (typeof option !== 'string' && option.value === value),
|
|
)
|
|
) {
|
|
return t('validation:invalidSelection')
|
|
}
|
|
|
|
if (
|
|
required &&
|
|
(typeof value === 'undefined' ||
|
|
value === null ||
|
|
(hasMany && Array.isArray(value) && (value as [])?.length === 0))
|
|
) {
|
|
return t('validation:required')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export type RadioFieldValidation = Validate<unknown, unknown, unknown, RadioField>
|
|
|
|
export const radio: RadioFieldValidation = (value, { options, req: { t }, required }) => {
|
|
if (value) {
|
|
const valueMatchesOption = options.some(
|
|
(option) => option === value || (typeof option !== 'string' && option.value === value),
|
|
)
|
|
return valueMatchesOption || t('validation:invalidSelection')
|
|
}
|
|
|
|
return required ? t('validation:required') : true
|
|
}
|
|
|
|
export type PointFieldValidation = Validate<
|
|
[number | string, number | string],
|
|
unknown,
|
|
unknown,
|
|
PointField
|
|
>
|
|
|
|
export const point: PointFieldValidation = (value = ['', ''], { req: { t }, required }) => {
|
|
const lng = parseFloat(String(value![0]))
|
|
const lat = parseFloat(String(value![1]))
|
|
if (
|
|
required &&
|
|
((value![0] && value![1] && typeof lng !== 'number' && typeof lat !== 'number') ||
|
|
Number.isNaN(lng) ||
|
|
Number.isNaN(lat) ||
|
|
(Array.isArray(value) && value.length !== 2))
|
|
) {
|
|
return t('validation:requiresTwoNumbers')
|
|
}
|
|
|
|
if ((value![1] && Number.isNaN(lng)) || (value![0] && Number.isNaN(lat))) {
|
|
return t('validation:invalidInput')
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Built-in field validations used by Payload
|
|
*
|
|
* These can be re-used in custom validations
|
|
*/
|
|
export const validations = {
|
|
array,
|
|
blocks,
|
|
checkbox,
|
|
code,
|
|
confirmPassword,
|
|
date,
|
|
email,
|
|
json,
|
|
number,
|
|
password,
|
|
point,
|
|
radio,
|
|
relationship,
|
|
richText,
|
|
select,
|
|
text,
|
|
textarea,
|
|
upload,
|
|
}
|