Merge pull request #2237 from payloadcms/feat/relationship-min-max-validation
feat: adds min and max options to relationship with hasMany
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<unknown, unknown, UploadField> = (value: string, o
|
||||
};
|
||||
|
||||
export const relationship: Validate<unknown, unknown, RelationshipField> = 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<unknown, unknown, RelationshipField> = 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<unknown, unknown, RelationshipField> = 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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user