diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 5878e0e645..a0c1ed1d04 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -20,11 +20,13 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma ### Config | Option | Description | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | | **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. | | **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | | **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. | +| **`min`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. | +| **`max`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. | | **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | | **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 57e5e4fbf6..c2231ccfaa 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -325,6 +325,10 @@ export const relationship = baseField.keys({ isSortable: joi.boolean().default(false), allowCreate: joi.boolean().default(true), }), + min: joi.number() + .when('hasMany', { is: joi.not(true), then: joi.forbidden() }), + max: joi.number() + .when('hasMany', { is: joi.not(true), then: joi.forbidden() }), }); export const blocks = baseField.keys({ diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index c58b853019..80fe14c9a6 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -290,7 +290,15 @@ export type RelationshipField = FieldBase & { isSortable?: boolean; allowCreate?: boolean; } -} +} & ({ + hasMany: true + min?: number + max?: number +} | { + hasMany?: false | undefined + min?: undefined + max?: undefined +}) export type ValueWithRelation = { relationTo: string diff --git a/src/fields/validations.spec.ts b/src/fields/validations.spec.ts index 2a7b5342f9..edfd95d190 100644 --- a/src/fields/validations.spec.ts +++ b/src/fields/validations.spec.ts @@ -1,4 +1,4 @@ -import { text, textarea, password, select, point, number } from './validations'; +import { text, textarea, password, select, point, number, relationship } from './validations'; import { ValidateOptions } from './config/types'; const t = jest.fn((string) => string); @@ -91,105 +91,174 @@ describe('Field Validations', () => { }); describe('password', () => { - options.type = 'password'; - options.name = 'test'; + const passwordOptions = { + ...options, + type: 'password', + name: 'test', + }; it('should validate', () => { const val = 'test'; - const result = password(val, options); + const result = password(val, passwordOptions); expect(result).toBe(true); }); it('should show required message', () => { const val = undefined; - const result = password(val, { ...options, required: true }); + const result = password(val, { ...passwordOptions, required: true }); expect(result).toBe('validation:required'); }); it('should handle undefined', () => { const val = undefined; - const result = password(val, options); + const result = password(val, passwordOptions); expect(result).toBe(true); }); it('should validate maxLength', () => { const val = 'toolong'; - const result = password(val, { ...options, maxLength: 5 }); + const result = password(val, { ...passwordOptions, maxLength: 5 }); expect(result).toBe('validation:shorterThanMax'); }); it('should validate minLength', () => { const val = 'short'; - const result = password(val, { ...options, minLength: 10 }); + const result = password(val, { ...passwordOptions, minLength: 10 }); expect(result).toBe('validation:longerThanMin'); }); it('should validate maxLength with no value', () => { const val = undefined; - const result = password(val, { ...options, maxLength: 5 }); + const result = password(val, { ...passwordOptions, maxLength: 5 }); expect(result).toBe(true); }); it('should validate minLength with no value', () => { const val = undefined; - const result = password(val, { ...options, minLength: 10 }); + const result = password(val, { ...passwordOptions, minLength: 10 }); expect(result).toBe(true); }); }); describe('point', () => { - options.type = 'point'; - options.name = 'point'; + const pointOptions = { + ...options, + type: 'point', + name: 'point', + }; it('should validate numbers', () => { const val = ['0.1', '0.2']; - const result = point(val, options); + const result = point(val, pointOptions); expect(result).toBe(true); }); it('should validate strings that could be numbers', () => { const val = ['0.1', '0.2']; - const result = point(val, options); + const result = point(val, pointOptions); expect(result).toBe(true); }); it('should show required message when undefined', () => { const val = undefined; - const result = point(val, { ...options, required: true }); + const result = point(val, { ...pointOptions, required: true }); expect(result).not.toBe(true); }); it('should show required message when array', () => { const val = []; - const result = point(val, { ...options, required: true }); + const result = point(val, { ...pointOptions, required: true }); expect(result).not.toBe(true); }); it('should show required message when array of undefined', () => { const val = [undefined, undefined]; - const result = point(val, { ...options, required: true }); + const result = point(val, { ...pointOptions, required: true }); expect(result).not.toBe(true); }); it('should handle undefined not required', () => { const val = undefined; - const result = password(val, options); + const result = password(val, pointOptions); expect(result).toBe(true); }); it('should handle empty array not required', () => { const val = []; - const result = point(val, options); + const result = point(val, pointOptions); expect(result).toBe(true); }); it('should handle array of undefined not required', () => { const val = [undefined, undefined]; - const result = point(val, options); + const result = point(val, pointOptions); expect(result).toBe(true); }); it('should prevent text input', () => { const val = ['bad', 'input']; - const result = point(val, options); + const result = point(val, pointOptions); expect(result).not.toBe(true); }); it('should prevent missing value', () => { const val = [0.1]; - const result = point(val, options); + const result = point(val, pointOptions); expect(result).not.toBe(true); }); }); - describe('select', () => { - options.type = 'select'; - options.options = ['one', 'two', 'three']; - const optionsRequired = { + describe('relationship', () => { + const relationshipOptions = { ...options, + relationTo: 'relation', + payload: { + collections: { + relation: { + config: { + slug: 'relation', + fields: [{ + name: 'id', + type: 'text', + }], + }, + }, + }, + }, + }; + it('should handle required', async () => { + const val = undefined; + const result = await relationship(val, { ...relationshipOptions, required: true }); + expect(result).not.toBe(true); + }); + it('should handle required with hasMany', async () => { + const val = []; + const result = await relationship(val, { ...relationshipOptions, required: true, hasMany: true }); + expect(result).not.toBe(true); + }); + it('should enforce hasMany min', async () => { + const minOptions = { + ...relationshipOptions, + hasMany: true, + min: 2, + }; + + const val = ['a']; + + const result = await relationship(val, minOptions); + expect(result).not.toBe(true); + + const allowed = await relationship(['a', 'b'], minOptions); + expect(allowed).toStrictEqual(true); + }); + it('should enforce hasMany max', async () => { + const maxOptions = { + ...relationshipOptions, + max: 2, + hasMany: true, + }; + let val = ['a', 'b', 'c']; + + const result = await relationship(val, maxOptions); + expect(result).not.toBe(true); + + val = ['a']; + const allowed = await relationship(val, maxOptions); + expect(allowed).toStrictEqual(true); + }); + }); + + describe('select', () => { + const selectOptions = { + ...options, + type: 'select', + options: ['one', 'two', 'three'], + }; + const optionsRequired = { + ...selectOptions, required: true, options: [{ value: 'one', @@ -203,7 +272,7 @@ describe('Field Validations', () => { }], }; const optionsWithEmptyString = { - ...options, + ...selectOptions, options: [{ value: '', label: 'None', @@ -214,27 +283,27 @@ describe('Field Validations', () => { }; it('should allow valid input', () => { const val = 'one'; - const result = select(val, options); + const result = select(val, selectOptions); expect(result).toStrictEqual(true); }); it('should prevent invalid input', () => { const val = 'bad'; - const result = select(val, options); + const result = select(val, selectOptions); expect(result).not.toStrictEqual(true); }); it('should allow null input', () => { const val = null; - const result = select(val, options); + const result = select(val, selectOptions); expect(result).toStrictEqual(true); }); it('should allow undefined input', () => { let val; - const result = select(val, options); + const result = select(val, selectOptions); expect(result).toStrictEqual(true); }); it('should prevent empty string input', () => { const val = ''; - const result = select(val, options); + const result = select(val, selectOptions); expect(result).not.toStrictEqual(true); }); it('should prevent undefined input with required', () => { @@ -293,12 +362,12 @@ describe('Field Validations', () => { }); it('should allow valid input with hasMany', () => { const val = ['one', 'two']; - const result = select(val, options); + const result = select(val, selectOptions); expect(result).toStrictEqual(true); }); it('should prevent invalid input with hasMany', () => { const val = ['one', 'bad']; - const result = select(val, options); + const result = select(val, selectOptions); expect(result).not.toStrictEqual(true); }); it('should allow valid input with hasMany option objects', () => { @@ -315,41 +384,44 @@ describe('Field Validations', () => { }); }); describe('number', () => { - options.type = 'number'; - options.name = 'test'; + const numberOptions = { + ...options, + type: 'number', + name: 'test', + }; it('should validate', () => { const val = 1; - const result = number(val, options); + const result = number(val, numberOptions); expect(result).toBe(true); }); it('should validate 2', () => { const val = 1.5; - const result = number(val, options); + const result = number(val, numberOptions); expect(result).toBe(true); }); it('should show invalid number message', () => { const val = 'test'; - const result = number(val, { ...options }); + const result = number(val, { ...numberOptions }); expect(result).toBe('validation:enterNumber'); }); it('should handle empty value', () => { const val = ''; - const result = number(val, { ...options }); + const result = number(val, { ...numberOptions }); expect(result).toBe(true); }); it('should handle required value', () => { const val = ''; - const result = number(val, { ...options, required: true }); + const result = number(val, { ...numberOptions, required: true }); expect(result).toBe('validation:enterNumber'); }); it('should validate minValue', () => { const val = 2.4; - const result = number(val, { ...options, min: 2.5 }); + const result = number(val, { ...numberOptions, min: 2.5 }); expect(result).toBe('validation:lessThanMin'); }); it('should validate maxValue', () => { const val = 1.25; - const result = number(val, { ...options, max: 1 }); + const result = number(val, { ...numberOptions, max: 1 }); expect(result).toBe('validation:greaterThanMax'); }); }); diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 2d214c8ff7..56332061b5 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -20,7 +20,6 @@ import { Validate, fieldAffectsData, } from './config/types'; -import { TypeWithID } from '../collections/config/types'; import canUseDOM from '../utilities/canUseDOM'; import { isValidID } from '../utilities/isValidID'; import { getIDType } from '../utilities/getIDType'; @@ -276,10 +275,29 @@ export const upload: Validate = (value: string, o }; export const relationship: Validate = async (value: RelationshipValue, options) => { - if ((!value || (Array.isArray(value) && value.length === 0)) && options.required) { + const { + required, + min, + max, + relationTo, + payload, + t, + } = options; + + if ((!value || (Array.isArray(value) && value.length === 0)) && required) { return options.t('validation:required'); } + if (Array.isArray(value)) { + if (min && value.length < min) { + return t('validation:lessThanMin', { count: min, label: t('rows') }); + } + + if (max && value.length > max) { + return t('validation:greaterThanMax', { count: max, label: t('rows') }); + } + } + if (!canUseDOM && typeof value !== 'undefined' && value !== null) { const values = Array.isArray(value) ? value : [value]; @@ -287,8 +305,8 @@ export const relationship: Validate = async let collection: string; let requestedID: string | number; - if (typeof options.relationTo === 'string') { - collection = options.relationTo; + if (typeof relationTo === 'string') { + collection = relationTo; // custom id if (typeof val === 'string' || typeof val === 'number') { @@ -296,12 +314,12 @@ export const relationship: Validate = async } } - if (Array.isArray(options.relationTo) && typeof val === 'object' && val?.relationTo) { + if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) { collection = val.relationTo; requestedID = val.value; } - const idField = options.payload.collections[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + const idField = payload.collections[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); let type; if (idField) { type = idField.type === 'number' ? 'number' : 'text'; diff --git a/test/fields/collections/Relationship/index.ts b/test/fields/collections/Relationship/index.ts index e6d76fe4a1..2a58ddcb71 100644 --- a/test/fields/collections/Relationship/index.ts +++ b/test/fields/collections/Relationship/index.ts @@ -39,6 +39,20 @@ const RelationshipFields: CollectionConfig = { value: user.id, }), }, + { + name: 'relationshipWithMin', + type: 'relationship', + relationTo: 'text-fields', + hasMany: true, + min: 2, + }, + { + name: 'relationshipWithMax', + type: 'relationship', + relationTo: 'text-fields', + hasMany: true, + max: 2, + }, ], };