Component unique fields (#20427)

* feat: wip component unique fields

* feat: wip component unique fields e2e test

* feat: repeatable component unique fields e2e test

* chore: update contributor docs

* chore: update types

* feat: e2e for repeatables

* chore: pr feedback

* chore: types

* chore(core): clean up entity validator unt tests

* Fix/nested-form-errors (#20496)

* fix(core): only highlight the relevant repeated values in components

* chore(core): set up component context for dynamic zones
This commit is contained in:
Jamie Howard 2024-06-18 14:55:34 +01:00 committed by GitHub
parent cefa185d29
commit 92fb9e9b23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1180 additions and 256 deletions

View File

@ -41,6 +41,18 @@ Once that's completed, you should be able to run your Strapi instance as usual:
yarn develop
```
> **Tip!**
> If you can't run the test-app because it is not present in the monorepo dependencies you can fix this by making the following change to the root monorepo `package.json` file and running `yarn install` at both the root of the monorepo and the test-app you are running.
>
> **This change should not be committed**.
```
"workspaces": [
...
"test-apps/e2e/*",
]
```
If you change any of the content schemas (including adding new ones) be sure to [update the `app-template`](./01-app-template.md) otherwise DTS will fail to import the data for schemas that do not exist.
### Exporting a data packet

View File

@ -131,6 +131,7 @@ interface FormProps<TFormValues extends FormValues = FormValues>
onSubmit?: (values: TFormValues, helpers: FormHelpers<TFormValues>) => Promise<void> | void;
// TODO: type the return value for a validation schema func from Yup.
validationSchema?: Yup.AnySchema;
initialErrors?: FormErrors<TFormValues>;
}
/**
@ -140,11 +141,11 @@ interface FormProps<TFormValues extends FormValues = FormValues>
* use the generic useForm hook or the useField hook when providing the name of your field.
*/
const Form = React.forwardRef<HTMLFormElement, FormProps>(
({ disabled = false, method, onSubmit, ...props }, ref) => {
({ disabled = false, method, onSubmit, initialErrors, ...props }, ref) => {
const formRef = React.useRef<HTMLFormElement>(null!);
const initialValues = React.useRef(props.initialValues ?? {});
const [state, dispatch] = React.useReducer(reducer, {
errors: {},
errors: initialErrors ?? {},
isSubmitting: false,
values: props.initialValues ?? {},
});
@ -487,7 +488,7 @@ type FormErrors<TFormValues extends FormValues = FormValues> = {
: string // this would let us support errors for the dynamic zone or repeatable component not the components within.
: TFormValues[Key] extends object // is it a regular component?
? FormErrors<TFormValues[Key]> // handles nested components
: string; // otherwise its just a field.
: string | TranslationMessage; // otherwise its just a field or a translation message.
};
interface FormState<TFormValues extends FormValues = FormValues> {
@ -669,12 +670,50 @@ const useField = <TValue = any,>(path: string): FieldValue<TValue | undefined> =
const handleChange = useForm('useField', (state) => state.onChange);
const error = useForm('useField', (state) => getIn(state.errors, path));
const formatNestedErrorMessages = (stateErrors: FormErrors<FormValues>) => {
const nestedErrors: Record<string, any> = {};
Object.entries(stateErrors).forEach(([key, value]) => {
let current = nestedErrors;
const pathParts = key.split('.');
pathParts.forEach((part, index) => {
const isLastPart = index === pathParts.length - 1;
if (isLastPart) {
if (typeof value === 'string') {
// If the value is a translation message object or a string, it should be nested as is
current[part] = value;
} else if (isErrorMessageDescriptor(value)) {
// If the value is a plain object, it should be converted to a string message
current[part] = formatMessage(value);
} else {
// If the value is not an object, it may be an array or a message
setIn(current, part, value);
}
} else {
// Ensure nested structure exists
if (!current[part]) {
const isArray = !isNaN(Number(pathParts[index + 1]));
current[part] = isArray ? [] : {};
}
current = current[part];
}
});
});
return nestedErrors;
};
const error = useForm('useField', (state) =>
getIn(formatNestedErrorMessages(state.errors), path)
);
return {
initialValue,
/**
* Errors can be a string, or a MesaageDescriptor, so we need to handle both cases.
* Errors can be a string, or a MessageDescriptor, so we need to handle both cases.
* If it's anything else, we don't return it.
*/
error: isErrorMessageDescriptor(error)
@ -693,9 +732,13 @@ const useField = <TValue = any,>(path: string): FieldValue<TValue | undefined> =
};
};
const isErrorMessageDescriptor = (object?: string | object): object is TranslationMessage => {
const isErrorMessageDescriptor = (object?: object): object is TranslationMessage => {
return (
typeof object === 'object' && object !== null && 'id' in object && 'defaultMessage' in object
typeof object === 'object' &&
object !== null &&
!Array.isArray(object) &&
'id' in object &&
'defaultMessage' in object
);
};

View File

@ -0,0 +1,100 @@
import { renderHook } from '@tests/utils';
import { Form, useField } from '../Form';
const createFormWrapper = (initialErrors: Record<string, any>) =>
function ({ children }: { children: React.ReactNode }) {
return (
<Form method="POST" initialErrors={initialErrors}>
{children}
</Form>
);
};
describe('useField hook', () => {
it('formats and returns nested error messages correctly for field constraints', () => {
const expectedError = 'This attribute must be unique';
const initialErrors = {
'repeatable.0.nestedUnique.TextShort': 'Another error message',
'repeatable.1.nestedUnique.nestedLevelOne.nestedLevelTwo.Unique': expectedError,
};
const { result } = renderHook(
() => useField('repeatable.1.nestedUnique.nestedLevelOne.nestedLevelTwo.Unique'),
{
wrapper: createFormWrapper(initialErrors),
}
);
expect(result.current.error).toEqual(expectedError);
});
it('formats and returns error messages correctly for translation message descriptors', () => {
const messageDescriptor = {
id: 'unique.attribute.error',
defaultMessage: 'This attribute must be unique',
};
const initialErrors = {
'nested.uniqueAttribute': messageDescriptor,
};
const { result } = renderHook(() => useField('nested.uniqueAttribute'), {
wrapper: createFormWrapper(initialErrors),
});
expect(result.current.error).toEqual('This attribute must be unique');
});
it('handles mixed error types correctly', () => {
const messageDescriptor = {
id: 'mixed.error',
defaultMessage: 'Mixed error message',
};
const initialErrors = {
'mixed.errorField': messageDescriptor,
'mixed.stringError': 'String error message',
'mixed.otherError': 123, // Non-string, non-descriptor error
};
const { result } = renderHook(() => useField('mixed.otherError'), {
wrapper: createFormWrapper(initialErrors),
});
expect(result.current.error).toBeUndefined();
});
it('handles errors associated with array indices', () => {
const initialErrors = {
'array.0.field': 'Error on first array item',
'array.1.field': 'Error on second array item',
};
const { result } = renderHook(() => useField('array.0.field'), {
wrapper: createFormWrapper(initialErrors),
});
expect(result.current.error).toEqual('Error on first array item');
});
it('returns undefined when there are no errors', () => {
const initialErrors = {};
const { result } = renderHook(() => useField('no.errors.field'), {
wrapper: createFormWrapper(initialErrors),
});
expect(result.current.error).toBeUndefined();
});
it('returns undefined for non-existent error paths', () => {
const initialErrors = {
'valid.path': 'Error message',
};
const { result } = renderHook(() => useField('invalid.path'), {
wrapper: createFormWrapper(initialErrors),
});
expect(result.current.error).toBeUndefined();
});
});

View File

@ -69,7 +69,14 @@ const formatErrorMessages = (errors: FormErrors, parentKey: string, formatMessag
)
);
} else {
messages.push(...formatErrorMessages(value, currentKey, formatMessage));
messages.push(
...formatErrorMessages(
// @ts-expect-error TODO: check why value is not compatible with FormErrors
value,
currentKey,
formatMessage
)
);
}
} else {
messages.push(

View File

@ -163,53 +163,7 @@ export const forms = {
form: {
advanced({ data, type, step, extensions, ...rest }: Base<'advanced'>) {
try {
const isComponentTarget = ['component', 'components'].includes(rest?.forTarget);
let baseForm;
if (isComponentTarget) {
const isUniqueEnabled = data?.unique ?? false;
// TODO: V5. This is a temporary measure as the behaviour of component unique fields is currently broken and will be worked on later.
// We are disabling the unique field checkbox if it's not enabled and if it's enabled we are explaining that it's not working and will be disabled if they change the setting.
// Remove this when the behaviour of component unique fields is fixed.
baseForm = attributesForm.advanced[type](data, step).sections.map((section) => {
//@ts-expect-error temporary measure
const filteredItems = section.items.map((item) => {
if (item.name !== 'unique') {
return item;
}
if (isUniqueEnabled) {
return {
...item,
description: {
id: 'content-type-builder.form.attribute.item.uniqueField.v5.willBeDisabled',
defaultMessage:
"Currently unique fields don't work correctly in components. If you disable this feature, the field will be disabled until this is fixed.",
},
};
}
return {
...item,
disabled: true,
description: {
id: 'content-type-builder.form.attribute.item.uniqueField.v5.disabled',
defaultMessage:
"Currently unique fields don't work correctly in components. This field has been disabled until it's fixed.",
},
};
});
return {
//@ts-expect-error temporary measure
sectionTitle: section?.sectionTitle ?? null,
items: filteredItems,
};
});
} else {
baseForm = attributesForm.advanced[type](data, step).sections;
}
const baseForm = attributesForm.advanced[type](data, step).sections;
const itemsToAdd = extensions.getAdvancedForm(['attribute', type], {
data,
type,

View File

@ -22,23 +22,23 @@ describe('BigInteger validator', () => {
};
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
test('it does not validate the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.biginteger(
@ -57,11 +57,11 @@ describe('BigInteger validator', () => {
await validator(1);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validate the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -82,11 +82,11 @@ describe('BigInteger validator', () => {
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.biginteger(
@ -108,7 +108,7 @@ describe('BigInteger validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrBigIntegerUnique: 2 });
fakeFindOne.mockResolvedValueOnce({ attrBigIntegerUnique: 2 });
const validator = strapiUtils.validateYupSchema(
validators.biginteger(
@ -133,7 +133,7 @@ describe('BigInteger validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrBigIntegerUnique: 3 });
fakeFindOne.mockResolvedValueOnce({ attrBigIntegerUnique: 3 });
const validator = strapiUtils.validateYupSchema(
validators.biginteger(
@ -154,7 +154,7 @@ describe('BigInteger validator', () => {
});
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.biginteger(
@ -173,7 +173,7 @@ describe('BigInteger validator', () => {
await validator(4);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
publishedAt: null,
locale: 'en',
@ -183,7 +183,7 @@ describe('BigInteger validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.biginteger(
@ -202,7 +202,7 @@ describe('BigInteger validator', () => {
await validator(5);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrBigIntegerUnique: 5,
id: {

View File

@ -22,23 +22,23 @@ describe('Date validator', () => {
};
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
test('it does not validates the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.date(
@ -54,11 +54,11 @@ describe('Date validator', () => {
await validator('2021-11-29');
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -75,11 +75,11 @@ describe('Date validator', () => {
);
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.date(
@ -98,7 +98,7 @@ describe('Date validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrDateUnique: '2021-11-29' });
fakeFindOne.mockResolvedValueOnce({ attrDateUnique: '2021-11-29' });
const validator = strapiUtils.validateYupSchema(
validators.date(
@ -120,7 +120,7 @@ describe('Date validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrDateUnique: '2021-11-29' });
fakeFindOne.mockResolvedValueOnce({ attrDateUnique: '2021-11-29' });
const validator = strapiUtils.validateYupSchema(
validators.date(
@ -138,7 +138,7 @@ describe('Date validator', () => {
});
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.date(
@ -154,7 +154,7 @@ describe('Date validator', () => {
await validator('2021-11-29');
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -164,7 +164,7 @@ describe('Date validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.date(
@ -180,7 +180,7 @@ describe('Date validator', () => {
await validator('2021-11-29');
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrDateUnique: '2021-11-29',
id: {

View File

@ -22,23 +22,23 @@ describe('Datetime validator', () => {
};
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
test('it does not validates the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.datetime(
@ -54,11 +54,11 @@ describe('Datetime validator', () => {
await validator('2021-11-29T00:00:00.000Z');
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -75,11 +75,11 @@ describe('Datetime validator', () => {
);
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.datetime(
@ -98,7 +98,7 @@ describe('Datetime validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrDateTimeUnique: '2021-11-29T00:00:00.000Z' });
fakeFindOne.mockResolvedValueOnce({ attrDateTimeUnique: '2021-11-29T00:00:00.000Z' });
const validator = strapiUtils.validateYupSchema(
validators.datetime(
@ -120,7 +120,7 @@ describe('Datetime validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrDateTimeUnique: '2021-11-29T00:00:00.000Z' });
fakeFindOne.mockResolvedValueOnce({ attrDateTimeUnique: '2021-11-29T00:00:00.000Z' });
const validator = strapiUtils.validateYupSchema(
validators.datetime(
@ -138,7 +138,7 @@ describe('Datetime validator', () => {
});
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.datetime(
@ -154,7 +154,7 @@ describe('Datetime validator', () => {
await validator('2021-11-29T00:00:00.000Z');
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -164,7 +164,7 @@ describe('Datetime validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.datetime(
@ -180,7 +180,7 @@ describe('Datetime validator', () => {
await validator('2021-11-29T00:00:00.000Z');
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrDateTimeUnique: '2021-11-29T00:00:00.000Z',
id: {

View File

@ -22,23 +22,23 @@ describe('Float validator', () => {
};
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
test('it does not validates the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.float(
@ -54,11 +54,11 @@ describe('Float validator', () => {
await validator(1);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -76,11 +76,11 @@ describe('Float validator', () => {
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.float(
@ -99,7 +99,7 @@ describe('Float validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrFloatUnique: 2 });
fakeFindOne.mockResolvedValueOnce({ attrFloatUnique: 2 });
const validator = strapiUtils.validateYupSchema(
validators.float(
@ -121,7 +121,7 @@ describe('Float validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrFloatUnique: 3 });
fakeFindOne.mockResolvedValueOnce({ attrFloatUnique: 3 });
const validator = strapiUtils.validateYupSchema(
validators.float(
@ -139,7 +139,7 @@ describe('Float validator', () => {
});
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.float(
@ -155,7 +155,7 @@ describe('Float validator', () => {
await validator(4);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -165,7 +165,7 @@ describe('Float validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.float(
@ -181,7 +181,7 @@ describe('Float validator', () => {
await validator(5);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrFloatUnique: 5,
id: {

View File

@ -22,23 +22,23 @@ describe('Integer validator', () => {
};
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
test('it does not validates the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.integer(
@ -54,11 +54,11 @@ describe('Integer validator', () => {
await validator(1);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -76,11 +76,11 @@ describe('Integer validator', () => {
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.integer(
@ -99,7 +99,7 @@ describe('Integer validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrIntegerUnique: 2 });
fakeFindOne.mockResolvedValueOnce({ attrIntegerUnique: 2 });
const validator = strapiUtils.validateYupSchema(
validators.integer(
@ -121,7 +121,7 @@ describe('Integer validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrIntegerUnique: 3 });
fakeFindOne.mockResolvedValueOnce({ attrIntegerUnique: 3 });
const validator = strapiUtils.validateYupSchema(
validators.integer(
@ -139,7 +139,7 @@ describe('Integer validator', () => {
});
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const valueToCheck = 4;
const validator = strapiUtils.validateYupSchema(
@ -156,7 +156,7 @@ describe('Integer validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -166,7 +166,7 @@ describe('Integer validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const valueToCheck = 5;
const validator = strapiUtils.validateYupSchema(
@ -183,7 +183,7 @@ describe('Integer validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrIntegerUnique: valueToCheck,
id: {

View File

@ -22,23 +22,23 @@ describe('String validator', () => {
};
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
test('it does not validates the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.string(
@ -57,11 +57,11 @@ describe('String validator', () => {
await validator('non-unique-test-data');
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -82,11 +82,11 @@ describe('String validator', () => {
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.string(
@ -108,7 +108,7 @@ describe('String validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrStringUnique: 'unique-test-data' });
fakeFindOne.mockResolvedValueOnce({ attrStringUnique: 'unique-test-data' });
const validator = strapiUtils.validateYupSchema(
validators.string(
@ -133,7 +133,7 @@ describe('String validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrStringUnique: 'non-updated-unique-test-data' });
fakeFindOne.mockResolvedValueOnce({ attrStringUnique: 'non-updated-unique-test-data' });
const validator = strapiUtils.validateYupSchema(
validators.string(
@ -154,7 +154,7 @@ describe('String validator', () => {
});
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const valueToCheck = 'test-data';
const validator = strapiUtils.validateYupSchema(
@ -174,7 +174,7 @@ describe('String validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
attrStringUnique: valueToCheck,
@ -184,7 +184,7 @@ describe('String validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const valueToCheck = 'test-data';
const validator = strapiUtils.validateYupSchema(
@ -204,7 +204,7 @@ describe('String validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrStringUnique: valueToCheck,
id: {

View File

@ -5,19 +5,19 @@ import { mockOptions } from './utils';
describe('Time validator', () => {
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
const fakeModel: Schema.ContentType = {
@ -38,7 +38,7 @@ describe('Time validator', () => {
};
test('it does not validates the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.time(
@ -54,11 +54,11 @@ describe('Time validator', () => {
await validator('00:00:00.000Z');
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -75,11 +75,11 @@ describe('Time validator', () => {
);
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.time(
@ -98,7 +98,7 @@ describe('Time validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrTimeUnique: '00:00:00.000Z' });
fakeFindOne.mockResolvedValueOnce({ attrTimeUnique: '00:00:00.000Z' });
const validator = strapiUtils.validateYupSchema(
validators.time(
@ -120,7 +120,7 @@ describe('Time validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrTimeUnique: '00:00:00.000Z' });
fakeFindOne.mockResolvedValueOnce({ attrTimeUnique: '00:00:00.000Z' });
const validator = strapiUtils.validateYupSchema(
validators.time(
@ -140,7 +140,7 @@ describe('Time validator', () => {
const valueToCheck = '00:00:00.000Z';
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.time(
@ -156,7 +156,7 @@ describe('Time validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -166,7 +166,7 @@ describe('Time validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.time(
@ -182,7 +182,7 @@ describe('Time validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrTimeUnique: valueToCheck,
id: {

View File

@ -6,19 +6,19 @@ import { mockOptions } from './utils';
describe('Time validator', () => {
describe('unique', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
const fakeModel: Schema.ContentType = {
@ -39,7 +39,7 @@ describe('Time validator', () => {
};
test('it does not validates the unique constraint if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.timestamp(
@ -58,11 +58,11 @@ describe('Time validator', () => {
await validator('1638140400');
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -82,11 +82,11 @@ describe('Time validator', () => {
);
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.timestamp(
@ -108,7 +108,7 @@ describe('Time validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrTimestampUnique: '1638140400' });
fakeFindOne.mockResolvedValueOnce({ attrTimestampUnique: '1638140400' });
const validator = strapiUtils.validateYupSchema(
validators.timestamp(
@ -133,7 +133,7 @@ describe('Time validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrTimestampUnique: '1638140400' });
fakeFindOne.mockResolvedValueOnce({ attrTimestampUnique: '1638140400' });
const validator = strapiUtils.validateYupSchema(
validators.timestamp(
@ -155,7 +155,7 @@ describe('Time validator', () => {
const valueToCheck = '1638140400';
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.timestamp(
@ -174,7 +174,7 @@ describe('Time validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -184,7 +184,7 @@ describe('Time validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.timestamp(
@ -203,7 +203,7 @@ describe('Time validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
attrTimestampUnique: valueToCheck,
id: {

View File

@ -5,19 +5,19 @@ import validators from '../validators';
import { mockOptions } from './utils';
describe('UID validator', () => {
const fakeFindFirst = jest.fn();
const fakeFindOne = jest.fn();
global.strapi = {
db: {
query: () => ({
findOne: fakeFindFirst,
findOne: fakeFindOne,
}),
},
} as any;
afterEach(() => {
jest.clearAllMocks();
fakeFindFirst.mockReset();
fakeFindOne.mockReset();
});
const fakeModel: Schema.ContentType = {
@ -39,7 +39,7 @@ describe('UID validator', () => {
describe('unique', () => {
test('it validates the unique constraint if there is no other record in the database', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.uid(
@ -54,11 +54,11 @@ describe('UID validator', () => {
);
expect(await validator('non-unique-uid')).toBe('non-unique-uid');
expect(fakeFindFirst).toHaveBeenCalled();
expect(fakeFindOne).toHaveBeenCalled();
});
test('it does not validates the unique constraint if the attribute value is `null`', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators
@ -76,11 +76,11 @@ describe('UID validator', () => {
await validator(null);
expect(fakeFindFirst).not.toHaveBeenCalled();
expect(fakeFindOne).not.toHaveBeenCalled();
});
test.only('it always validates the unique constraint even if the attribute is not set as unique', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const valueToCheck = 'non-unique-uid';
const validator = strapiUtils.validateYupSchema(
@ -96,7 +96,7 @@ describe('UID validator', () => {
);
expect(await validator(valueToCheck)).toBe(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -107,7 +107,7 @@ describe('UID validator', () => {
test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce({ attrUidUnique: 'unique-uid' });
fakeFindOne.mockResolvedValueOnce({ attrUidUnique: 'unique-uid' });
const validator = strapiUtils.validateYupSchema(
validators.uid(
@ -129,7 +129,7 @@ describe('UID validator', () => {
});
test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => {
fakeFindFirst.mockResolvedValueOnce({ attrUidUnique: 'unchanged-unique-uid' });
fakeFindOne.mockResolvedValueOnce({ attrUidUnique: 'unchanged-unique-uid' });
const validator = strapiUtils.validateYupSchema(
validators.uid(
@ -148,7 +148,7 @@ describe('UID validator', () => {
const valueToCheck = 'unique-uid';
test('it checks the database for records with the same value for the checked attribute', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.uid(
@ -164,7 +164,7 @@ describe('UID validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -174,7 +174,7 @@ describe('UID validator', () => {
});
test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.uid(
@ -190,7 +190,7 @@ describe('UID validator', () => {
await validator(valueToCheck);
expect(fakeFindFirst).toHaveBeenCalledWith({
expect(fakeFindOne).toHaveBeenCalledWith({
where: {
locale: 'en',
publishedAt: null,
@ -203,7 +203,7 @@ describe('UID validator', () => {
describe('regExp', () => {
test('it fails to validate the uid if it does not fit the requried format', async () => {
expect.assertions(1);
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.uid(
@ -225,7 +225,7 @@ describe('UID validator', () => {
});
test('it validate the uid if it fit the required format', async () => {
fakeFindFirst.mockResolvedValueOnce(null);
fakeFindOne.mockResolvedValueOnce(null);
const validator = strapiUtils.validateYupSchema(
validators.uid(

View File

@ -15,16 +15,32 @@ const { yup, validateYupSchema } = strapiUtils;
const { isMediaAttribute, isScalarAttribute, getWritableAttributes } = strapiUtils.contentTypes;
const { ValidationError } = strapiUtils.errors;
type Entity = {
id: ID;
[key: string]: unknown;
} | null;
type ID = { id: string | number };
type RelationSource = string | number | ID;
interface ValidatorMeta<TAttribute = Schema.Attribute.AnyAttribute> {
export type ComponentContext = {
parentContent: {
// The model of the parent content type that contains the current component.
model: Struct.Schema;
// The numeric id of the parent entity that contains the component.
id?: number;
// The options passed to the entity validator. From which we can extract
// entity dimensions such as locale and publication state.
options?: ValidatorContext;
};
// The path to the component within the parent content type schema.
pathToComponent: string[];
// If working with a repeatable component this contains the
// full data of the repeatable component in the current entity.
repeatableData: Modules.EntityValidator.Entity[];
};
interface WithComponentContext {
componentContext?: ComponentContext;
}
interface ValidatorMeta<TAttribute = Schema.Attribute.AnyAttribute> extends WithComponentContext {
attr: TAttribute;
updatedAttribute: { name: string; value: any };
}
@ -34,17 +50,17 @@ interface ValidatorContext {
locale?: string | null;
}
interface AttributeValidatorMetas {
interface AttributeValidatorMetas extends WithComponentContext {
attr: Schema.Attribute.AnyAttribute;
updatedAttribute: { name: string; value: unknown };
model: Struct.Schema;
entity?: Entity;
entity?: Modules.EntityValidator.Entity;
}
interface ModelValidatorMetas {
interface ModelValidatorMetas extends WithComponentContext {
model: Struct.Schema;
data: Record<string, unknown>;
entity?: Entity;
entity?: Modules.EntityValidator.Entity;
}
const isInteger = (value: unknown): value is number => Number.isInteger(value);
@ -128,7 +144,11 @@ const preventCast = (validator: strapiUtils.yup.AnySchema) =>
const createComponentValidator =
(createOrUpdate: CreateOrUpdate) =>
(
{ attr, updatedAttribute }: ValidatorMeta<Schema.Attribute.Component<UID.Component, boolean>>,
{
attr,
updatedAttribute,
componentContext,
}: ValidatorMeta<Schema.Attribute.Component<UID.Component, boolean>>,
{ isDraft }: ValidatorContext
) => {
const model = strapi.getModel(attr.component);
@ -143,7 +163,10 @@ const createComponentValidator =
.array()
.of(
yup.lazy((item) =>
createModelValidator(createOrUpdate)({ model, data: item }, { isDraft }).notNull()
createModelValidator(createOrUpdate)(
{ componentContext, model, data: item },
{ isDraft }
).notNull()
) as any
);
@ -157,9 +180,12 @@ const createComponentValidator =
return validator;
}
// FIXME: v4 was broken
let validator = createModelValidator(createOrUpdate)(
{ model, data: updatedAttribute.value },
{
model,
data: updatedAttribute.value,
componentContext,
},
{ isDraft }
);
@ -173,7 +199,7 @@ const createComponentValidator =
const createDzValidator =
(createOrUpdate: CreateOrUpdate) =>
({ attr, updatedAttribute }: ValidatorMeta, { isDraft }: ValidatorContext) => {
({ attr, updatedAttribute, componentContext }: ValidatorMeta, { isDraft }: ValidatorContext) => {
let validator;
validator = yup.array().of(
@ -187,7 +213,12 @@ const createDzValidator =
.notNull();
return model
? schema.concat(createModelValidator(createOrUpdate)({ model, data: item }, { isDraft }))
? schema.concat(
createModelValidator(createOrUpdate)(
{ model, data: item, componentContext },
{ isDraft }
)
)
: schema;
}) as any // FIXME: yup v1
);
@ -254,12 +285,56 @@ const createAttributeValidator =
validator = createScalarAttributeValidator(createOrUpdate)(metas, options);
} else {
if (metas.attr.type === 'component') {
// Build the path to the component within the parent content type schema.
const pathToComponent = [
...(metas?.componentContext?.pathToComponent ?? []),
metas.updatedAttribute.name,
];
// If working with a repeatable component, determine the repeatable data
// based on the component's path.
// In order to validate the repeatable within this entity we need
// access to the full repeatable data. In case we are validating a
// nested component within a repeatable.
// Hence why we set this up when the path to the component is only one level deep.
const repeatableData = (
metas.attr.repeatable && pathToComponent.length === 1
? metas.updatedAttribute.value
: metas.componentContext?.repeatableData
) as Modules.EntityValidator.Entity[];
const newComponentContext = {
...(metas?.componentContext ?? {}),
pathToComponent,
repeatableData,
};
validator = createComponentValidator(createOrUpdate)(
{ attr: metas.attr, updatedAttribute: metas.updatedAttribute },
{
componentContext: newComponentContext as ComponentContext,
attr: metas.attr,
updatedAttribute: metas.updatedAttribute,
},
options
);
} else if (metas.attr.type === 'dynamiczone') {
validator = createDzValidator(createOrUpdate)(metas, options);
// TODO: fix! query layer fails when building a where for dynamic
// zones
const pathToComponent = [
...(metas?.componentContext?.pathToComponent ?? []),
metas.updatedAttribute.name,
];
const newComponentContext = {
...(metas?.componentContext ?? {}),
pathToComponent,
};
validator = createDzValidator(createOrUpdate)(
{ ...metas, componentContext: newComponentContext as ComponentContext },
options
);
} else if (metas.attr.type === 'relation') {
validator = createRelationValidator(createOrUpdate)(
{
@ -280,7 +355,7 @@ const createAttributeValidator =
const createModelValidator =
(createOrUpdate: CreateOrUpdate) =>
({ model, data, entity }: ModelValidatorMetas, options: ValidatorContext) => {
({ componentContext, model, data, entity }: ModelValidatorMetas, options: ValidatorContext) => {
const writableAttributes = model ? getWritableAttributes(model as any) : [];
const schema = writableAttributes.reduce(
@ -290,6 +365,7 @@ const createModelValidator =
updatedAttribute: { name: attributeName, value: prop(attributeName, data) },
model,
entity,
componentContext,
};
const validator = createAttributeValidator(createOrUpdate)(metas, options);
@ -312,7 +388,7 @@ const createValidateEntity = (createOrUpdate: CreateOrUpdate) => {
model: Schema.ContentType<TUID>,
data: TData | Partial<TData> | undefined,
options?: ValidatorContext,
entity?: Entity
entity?: Modules.EntityValidator.Entity
): Promise<TData> => {
if (!isObject(data)) {
const { displayName } = model.info;
@ -323,23 +399,43 @@ const createValidateEntity = (createOrUpdate: CreateOrUpdate) => {
}
const validator = createModelValidator(createOrUpdate)(
{ model, data, entity },
{
model,
data,
entity,
componentContext: {
// Set up the initial component context.
// Keeping track of parent content type context in which a component will be used.
// This is necessary to validate component field constraints such as uniqueness.
parentContent: {
id: entity?.id,
model,
options,
},
pathToComponent: [],
repeatableData: [],
},
},
{
isDraft: options?.isDraft ?? false,
locale: options?.locale ?? null,
}
)
.test('relations-test', 'check that all relations exist', async function (data) {
try {
await checkRelationsExist(buildRelationsStore({ uid: model.uid, data }));
} catch (e) {
return this.createError({
path: this.path,
message: (e instanceof ValidationError && e.message) || 'Invalid relations',
});
.test(
'relations-test',
'check that all relations exist',
async function relationsValidation(data) {
try {
await checkRelationsExist(buildRelationsStore({ uid: model.uid, data }));
} catch (e) {
return this.createError({
path: this.path,
message: (e instanceof ValidationError && e.message) || 'Invalid relations',
});
}
return true;
}
return true;
})
)
.required();
return validateYupSchema(validator, {

View File

@ -1,15 +1,16 @@
import _ from 'lodash';
import strapiUtils from '@strapi/utils';
import type { Schema, Struct } from '@strapi/types';
import { yup } from '@strapi/utils';
import type { Schema, Struct, Modules } from '@strapi/types';
import blocksValidator from './blocks-validator';
const { yup } = strapiUtils;
import type { ComponentContext } from '.';
interface ValidatorMetas<TAttribute extends Schema.Attribute.AnyAttribute> {
attr: TAttribute;
model: Struct.ContentTypeSchema;
updatedAttribute: { name: string; value: unknown };
entity: Record<string, unknown> | null;
entity: Modules.EntityValidator.Entity;
componentContext: ComponentContext;
}
interface ValidatorOptions {
@ -23,7 +24,7 @@ interface ValidatorOptions {
* Adds minLength validator
*/
const addMinLengthValidator = (
validator: strapiUtils.yup.StringSchema,
validator: yup.StringSchema,
{
attr,
}: {
@ -47,7 +48,7 @@ const addMinLengthValidator = (
* @returns {StringSchema}
*/
const addMaxLengthValidator = (
validator: strapiUtils.yup.StringSchema,
validator: yup.StringSchema,
{
attr,
}: {
@ -68,7 +69,7 @@ const addMaxLengthValidator = (
* @returns {NumberSchema}
*/
const addMinIntegerValidator = (
validator: strapiUtils.yup.NumberSchema,
validator: yup.NumberSchema,
{
attr,
}: {
@ -80,7 +81,7 @@ const addMinIntegerValidator = (
* Adds max integer validator
*/
const addMaxIntegerValidator = (
validator: strapiUtils.yup.NumberSchema,
validator: yup.NumberSchema,
{
attr,
}: {
@ -92,7 +93,7 @@ const addMaxIntegerValidator = (
* Adds min float/decimal validator
*/
const addMinFloatValidator = (
validator: strapiUtils.yup.NumberSchema,
validator: yup.NumberSchema,
{
attr,
}: {
@ -104,7 +105,7 @@ const addMinFloatValidator = (
* Adds max float/decimal validator
*/
const addMaxFloatValidator = (
validator: strapiUtils.yup.NumberSchema,
validator: yup.NumberSchema,
{
attr,
}: {
@ -116,7 +117,7 @@ const addMaxFloatValidator = (
* Adds regex validator
*/
const addStringRegexValidator = (
validator: strapiUtils.yup.StringSchema,
validator: yup.StringSchema,
{
attr,
}: {
@ -137,13 +138,14 @@ const addStringRegexValidator = (
/**
* Adds unique validator
*/
const addUniqueValidator = <T extends strapiUtils.yup.AnySchema>(
const addUniqueValidator = <T extends yup.AnySchema>(
validator: T,
{
attr,
model,
updatedAttribute,
entity,
componentContext,
}: ValidatorMetas<Schema.Attribute.AnyAttribute & Schema.Attribute.UniqueOption>,
options: ValidatorOptions
): T => {
@ -172,20 +174,100 @@ const addUniqueValidator = <T extends strapiUtils.yup.AnySchema>(
return true;
}
/**
* At this point we know that we are creating a new entry, publishing an entry or that the unique field value has changed
* We check if there is an entry of this content type in the same locale, publication state and with the same unique field value
*/
const record = await strapi.db.query(model.uid).findOne({
where: {
locale: options.locale,
publishedAt: options.isDraft ? null : { $notNull: true },
[updatedAttribute.name]: value,
...(entity?.id ? { id: { $ne: entity.id } } : {}),
},
});
let queryUid: string;
let queryWhere: Record<string, any> = {};
return !record;
if (componentContext) {
const hasRepeatableData = componentContext.repeatableData.length > 0;
if (hasRepeatableData) {
// If we are validating a unique field within a repeatable component,
// we first need to ensure that the repeatable in the current entity is
// valid against itself.
const { name: updatedName, value: updatedValue } = updatedAttribute;
// Construct the full path to the unique field within the component.
const pathToCheck = [...componentContext.pathToComponent.slice(1), updatedName].join('.');
// Extract the values from the repeatable data using the constructed path
const values = componentContext.repeatableData.map((item) => {
return pathToCheck.split('.').reduce((acc, key) => acc[key], item as any);
});
// Check if the value is repeated in the current entity
const isUpdatedAttributeRepeatedInThisEntity =
values.filter((value) => value === updatedValue).length > 1;
if (isUpdatedAttributeRepeatedInThisEntity) {
return false;
}
}
/**
* When `componentContext` is present it means we are dealing with a unique
* field within a component.
*
* The unique validation must consider the specific context of the
* component, which will always be contained within a parent content type
* and may also be nested within another component.
*
* We construct a query that takes into account the parent's model UID,
* dimensions (such as draft and publish state/locale) and excludes the current
* content type entity by its ID if provided.
*/
const {
model: parentModel,
options: parentOptions,
id: excludeId,
} = componentContext.parentContent;
queryUid = parentModel.uid;
const whereConditions: Record<string, any> = {};
const isParentDraft = parentOptions && parentOptions.isDraft;
whereConditions.publishedAt = isParentDraft ? null : { $notNull: true };
if (parentOptions?.locale) {
whereConditions.locale = parentOptions.locale;
}
if (excludeId && !Number.isNaN(excludeId)) {
whereConditions.id = { $ne: excludeId };
}
queryWhere = {
...componentContext.pathToComponent.reduceRight((acc, key) => ({ [key]: acc }), {
[updatedAttribute.name]: value,
}),
...whereConditions,
};
} else {
/**
* Here we are validating a scalar unique field from the content type's schema.
* We construct a query to check if the value is unique
* considering dimensions (e.g. locale, publication state) and excluding the current entity by its ID if available.
*/
queryUid = model.uid;
const scalarAttributeWhere: Record<string, any> = {
[updatedAttribute.name]: value,
};
scalarAttributeWhere.publishedAt = options.isDraft ? null : { $notNull: true };
if (options?.locale) {
scalarAttributeWhere.locale = options.locale;
}
if (entity?.id) {
scalarAttributeWhere.id = { $ne: entity.id };
}
queryWhere = scalarAttributeWhere;
}
// The validation should pass if there is no other record found from the query
// TODO query not working for dynamic zones (type === relation)
return !(await strapi.db.query(queryUid).findOne({ where: queryWhere }));
});
};

View File

@ -3,13 +3,11 @@ import type * as UID from '../uid';
import type * as EntityService from './entity-service';
type Entity = {
id: ID;
export type Entity = {
id: number;
[key: string]: unknown;
} | null;
type ID = { id: string | number };
type Options = { isDraft?: boolean; locale?: string };
export interface EntityValidator {

View File

@ -49,6 +49,29 @@ module.exports = {
},
},
},
// article.compo-unique-top-level contains a nested component with every
// kind of unique field.
// These are used to test the validation of component unique fields in every case.
identifiers: {
type: 'component',
repeatable: false,
component: 'article.compo-unique-top-level',
pluginOptions: {
i18n: {
localized: true,
},
},
},
repeatableIdentifiers: {
type: 'component',
repeatable: true,
component: 'article.compo-unique-top-level',
pluginOptions: {
i18n: {
localized: true,
},
},
},
categories: {
type: 'relation',
relation: 'manyToMany',

View File

@ -0,0 +1,100 @@
'use strict';
module.exports = {
collectionName: 'components_unique_all',
displayName: 'compo_unique_all',
singularName: 'compo_unique_all',
category: 'article',
attributes: {
ComponentTextShort: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'string',
unique: true,
},
ComponentTextLong: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'text',
unique: true,
},
ComponentNumberInteger: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'integer',
unique: true,
},
ComponentNumberBigInteger: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'biginteger',
unique: true,
},
ComponentNumberDecimal: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'decimal',
unique: true,
},
ComponentNumberFloat: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'float',
unique: true,
},
ComponentEmail: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'email',
unique: true,
},
ComponentDateDate: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'date',
unique: true,
},
ComponentDateDateTime: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'datetime',
unique: true,
},
ComponentDateTime: {
pluginOptions: {
i18n: {
localized: true,
},
},
type: 'time',
unique: true,
},
},
};

View File

@ -0,0 +1,20 @@
'use strict';
module.exports = {
collectionName: 'components_unique_top_level',
displayName: 'compo_unique_top_level',
singularName: 'compo_unique_top_level',
category: 'article',
attributes: {
nestedUnique: {
type: 'component',
repeatable: false,
component: 'article.compo-unique-all',
pluginOptions: {
i18n: {
localized: true,
},
},
},
},
};

View File

@ -10,5 +10,7 @@ module.exports = {
'article.comp': require('./comp'),
'article.dz_comp': require('./dz-comp'),
'article.dz_other_comp': require('./dz-other-comp'),
'article.compo_unique_all': require('./compo-unique-all'),
'article.compo_unique_top_level': require('./compo-unique-top-level'),
},
};

View File

@ -32,12 +32,43 @@ export interface ArticleDzOtherComp extends Schema.Component {
};
}
export interface ArticleCompoUniqueAll extends Schema.Component {
collectionName: 'components_unique_all';
info: {
displayName: 'compo_unique_all';
};
attributes: {
ComponentTextShort: Attribute.String;
ComponentTextLong: Attribute.Text;
ComponentNumberInteger: Attribute.Integer;
ComponentNumberBigInteger: Attribute.BigInteger;
ComponentNumberDecimal: Attribute.Decimal;
ComponentNumberFloat: Attribute.Float;
ComponentEmail: Attribute.Email;
ComponentDateDate: Attribute.Date;
ComponentDateDateTime: Attribute.DateTime;
ComponentDateTime: Attribute.Time;
};
}
export interface ArticleCompoUniqueTopLevel extends Schema.Component {
collectionName: 'components_unique_top_level';
info: {
displayName: 'compo_unique_top_level';
};
attributes: {
nestedUnique: Attribute.Component;
};
}
declare module '@strapi/types' {
export module Shared {
export interface Components {
'article.comp': ArticleComp;
'article.dz-comp': ArticleDzComp;
'article.dz-other-comp': ArticleDzOtherComp;
'article.compo_unique_all': ArticleCompoUniqueAll;
'article.compo_unique_top_level': ArticleCompoUniqueTopLevel;
}
}
}

View File

@ -672,6 +672,8 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
'api::article.article'
>;
locale: Schema.Attribute.String;
identifiers: Schema.Attribute.Component;
repeatableIdentifiers: Schema.Attribute.Component;
};
}

View File

@ -2,7 +2,7 @@ import type { Core } from '@strapi/types';
import { createTestSetup, destroyTestSetup } from '../../../utils/builder-helper';
import resources from './resources/index';
import { CATEGORY_UID, Category } from './utils';
import { ARTICLE_UID, CATEGORY_UID, Category } from './utils';
describe('Document Service', () => {
let testUtils;
@ -21,7 +21,7 @@ describe('Document Service', () => {
await destroyTestSetup(testUtils);
});
describe('Unique fields', () => {
describe('Scalar unique fields', () => {
it('cannot create a document with a duplicated unique field value in the same publication state', async () => {
expect(async () => {
await strapi.documents(CATEGORY_UID).create({
@ -70,4 +70,188 @@ describe('Document Service', () => {
).rejects.toThrow();
});
});
describe('Component unique fields', () => {
const uniqueTextShort = 'unique-text-short';
const uniqueTextLong = 'This is a unique long text used for testing purposes.';
const uniqueNumberInteger = 42;
const uniqueNumberDecimal = 3.14;
const uniqueNumberBigInteger = 1234567890123;
const uniqueNumberFloat = 6.28318;
const uniqueEmail = 'unique@example.com';
const uniqueDateDate = '2023-01-01';
const uniqueDateDateTime = '2023-01-01T00:00:00.000Z';
const uniqueDateTime = '12:00:00';
const testValues = {
ComponentTextShort: uniqueTextShort,
ComponentTextLong: uniqueTextLong,
ComponentNumberInteger: uniqueNumberInteger,
ComponentNumberDecimal: uniqueNumberDecimal,
ComponentNumberBigInteger: uniqueNumberBigInteger,
ComponentNumberFloat: uniqueNumberFloat,
ComponentEmail: uniqueEmail,
ComponentDateDate: uniqueDateDate,
ComponentDateDateTime: uniqueDateDateTime,
ComponentDateTime: uniqueDateTime,
};
const otherLocale = 'fr';
/**
* Modifies the given value to ensure uniqueness based on the field type.
* For 'Number' fields, it increments the value by a specified amount.
* For 'Date' fields, it increments the last number found in the string representation of the date.
* For other field types, it appends '-different' to the string representation of the value.
*/
const modifyToDifferentValue = (
field: string,
currentValue: string | number,
increment = 1
) => {
if (field.includes('Number')) {
return (currentValue as number) + increment;
} else if (field.includes('Date')) {
return (currentValue as string).replace(/(\d+)(?=\D*$)/, (match) => {
const num = parseInt(match, 10) + increment;
return num < 10 ? `0${num}` : num.toString();
});
}
return `${currentValue}-different`;
};
for (const [field, value] of Object.entries(testValues)) {
it(`cannot create multiple entities with the same unique ${field} value in the same locale and publication state`, async () => {
// Create an article in the default locale and publish it
const article = await strapi.documents(ARTICLE_UID).create({
data: {
identifiers: {
nestedUnique: {
[field]: value,
},
},
},
});
await strapi.documents(ARTICLE_UID).publish({ documentId: article.documentId });
// Create and publish an article in a different locale with the same
// unique value as the first article
const articleDifferentLocale = await strapi.documents(ARTICLE_UID).create({
data: {
identifiers: {
nestedUnique: {
[field]: value,
},
},
},
locale: otherLocale,
});
expect(articleDifferentLocale).toBeDefined();
await strapi
.documents(ARTICLE_UID)
.publish({ documentId: articleDifferentLocale.documentId, locale: otherLocale });
// Attempt to create another article in the default locale with the same unique value
// The draft articles should collide and trigger a uniqueness error
await expect(
strapi.documents(ARTICLE_UID).create({
data: {
identifiers: {
nestedUnique: {
[field]: value,
},
},
},
})
).rejects.toThrow('This attribute must be unique');
const differentValue = modifyToDifferentValue(field, value);
// Create an article in the same locale with a different unique value
const secondArticle = await strapi.documents(ARTICLE_UID).create({
data: {
identifiers: {
nestedUnique: {
[field]: differentValue,
},
},
},
});
expect(secondArticle).toBeDefined();
});
it(`cannot create an entity with repeated unique ${field} value within a repeatable component in the same locale and publication state`, async () => {
// Attempt to create an article with the same unique value in a repeatable component.
await expect(
strapi.documents(ARTICLE_UID).create({
data: {
repeatableIdentifiers: [
{ nestedUnique: { [field]: value } },
{ nestedUnique: { [field]: value } },
],
},
})
).rejects.toThrow('2 errors occurred');
let differentValue = modifyToDifferentValue(field, value);
// Successfully create and publish an article with a unique value in a repeatable component and publish it.
const firstArticle = await strapi.documents(ARTICLE_UID).create({
data: {
repeatableIdentifiers: [
{ nestedUnique: { [field]: value } },
{ nestedUnique: { [field]: differentValue } },
],
},
});
expect(firstArticle).toBeDefined();
await strapi.documents(ARTICLE_UID).publish({ documentId: firstArticle.documentId });
// Successfully create and publish another article with the same unique value in a repeatable component in a different locale.
const secondArticleDifferentLocale = await strapi.documents(ARTICLE_UID).create({
data: {
repeatableIdentifiers: [
{ nestedUnique: { [field]: differentValue } },
{ nestedUnique: { [field]: value } },
],
},
locale: otherLocale,
});
expect(secondArticleDifferentLocale).toBeDefined();
differentValue = modifyToDifferentValue(field, differentValue);
// Attempt to create another article with the same unique value in a repeatable component
// This should fail because the value must be unique across all entries in the same locale.
await expect(
strapi.documents(ARTICLE_UID).create({
data: {
repeatableIdentifiers: [
{ nestedUnique: { [field]: differentValue } },
{ nestedUnique: { [field]: value } },
],
},
})
).rejects.toThrow('This attribute must be unique');
differentValue = modifyToDifferentValue(field, differentValue);
const secondArticleWithDifferentValues = await strapi.documents(ARTICLE_UID).create({
data: {
repeatableIdentifiers: [
{ nestedUnique: { [field]: differentValue } },
{
nestedUnique: {
[field]: modifyToDifferentValue(field, differentValue),
},
},
],
},
});
// Verify that the article with different values was successfully created
expect(secondArticleWithDifferentValues).toBeDefined();
});
}
});
});

View File

@ -58,6 +58,26 @@
}
},
"type": "uid"
},
"identifiers": {
"type": "component",
"repeatable": false,
"component": "unique.top-level",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"repeatableIdentifiers": {
"type": "component",
"repeatable": true,
"component": "unique.top-level",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}

View File

@ -0,0 +1,100 @@
{
"collectionName": "components_unique_all",
"category": "unique",
"info": {
"displayName": "All",
"description": "Contains all the unique fields types."
},
"attributes": {
"ComponentTextShort": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "string",
"unique": true
},
"ComponentTextLong": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "text",
"unique": true
},
"ComponentNumberInteger": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "integer",
"unique": true
},
"ComponentNumberBigInteger": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "biginteger",
"unique": true
},
"ComponentNumberDecimal": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "decimal",
"unique": true
},
"ComponentNumberFloat": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "float",
"unique": true
},
"ComponentEmail": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "email",
"unique": true
},
"ComponentDateDate": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "date",
"unique": true
},
"ComponentDateDateTime": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "datetime",
"unique": true
},
"ComponentDateTime": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "time",
"unique": true
}
}
}

View File

@ -0,0 +1,20 @@
{
"collectionName": "components_unique_top_level",
"category": "unique",
"info": {
"displayName": "Top Level",
"description": "Contains a nested component with all the possible unique field types."
},
"attributes": {
"nestedUnique": {
"type": "component",
"repeatable": false,
"component": "unique.all",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}

Binary file not shown.

View File

@ -1,4 +1,4 @@
const { exportData } = require('../utils/dts-export');
const { exportData } = require('../utils/dts-export.ts');
// TODO: make an actual yargs command and pass common options to exportData so it's easier to build the test data
exportData();

View File

@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect, Page } from '@playwright/test';
import { login } from '../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { findAndClose } from '../../utils/shared';
@ -7,7 +7,10 @@ type Field = {
name: string;
value: string;
newValue?: string;
role?: string;
role?: 'combobox' | 'textbox';
component?: {
isSingle: boolean;
};
};
test.describe('Uniqueness', () => {
@ -22,31 +25,134 @@ test.describe('Uniqueness', () => {
await page.getByRole('link', { name: 'Unique' }).click();
});
const FIELDS_TO_TEST = [
const SCALAR_FIELDS_TO_TEST: Field[] = [
{ name: 'uniqueString', value: 'unique', newValue: 'unique-1' },
{ name: 'uniqueNumber', value: '10', newValue: '20' },
{ name: 'uniqueEmail', value: 'test@testing.com', newValue: 'editor@testing.com' },
{ name: 'uniqueEmail', value: 'test@strapi.io', newValue: 'test+update@strapi.io' },
{ name: 'uniqueDate', value: '01/01/2024', newValue: '02/01/2024', role: 'combobox' },
{ name: 'UID', value: 'unique', newValue: 'unique-1' },
];
const SINGLE_COMPONENT_FIELDS_TO_TEST: Field[] = [
{
name: 'ComponentTextShort',
value: 'unique',
newValue: 'unique-1',
component: { isSingle: true },
},
{
name: 'ComponentTextLong',
value: 'unique',
newValue: 'unique-1',
component: { isSingle: true },
},
{
name: 'ComponentNumberInteger',
value: '10',
newValue: '20',
component: { isSingle: true },
},
{
name: 'ComponentNumberFloat',
value: '3.14',
newValue: '3.1415926535897',
component: { isSingle: true },
},
{
name: 'ComponentEmail',
value: 'test@strapi.io',
newValue: 'test+update@strapi.io',
component: { isSingle: true },
},
];
const REPEATABLE_COMPONENT_FIELDS_TO_TEST: Field[] = [
{
name: 'ComponentTextShort',
value: 'unique',
newValue: 'unique-2',
component: { isSingle: false },
},
{
name: 'ComponentTextLong',
value: 'unique',
newValue: 'unique-2',
component: { isSingle: false },
},
{
name: 'ComponentNumberInteger',
value: '10',
newValue: '20',
component: { isSingle: false },
},
{
name: 'ComponentNumberFloat',
value: '3.14',
newValue: '3.1415926535897',
component: { isSingle: false },
},
{
name: 'ComponentEmail',
value: 'test@strapi.io',
newValue: 'test+update@strapi.io',
component: { isSingle: false },
},
];
const FIELDS_TO_TEST = [
...SCALAR_FIELDS_TO_TEST,
...SINGLE_COMPONENT_FIELDS_TO_TEST,
...REPEATABLE_COMPONENT_FIELDS_TO_TEST,
] as const satisfies Array<Field>;
const CREATE_URL =
/\/admin\/content-manager\/collection-types\/api::unique.unique\/create(\?.*)?/;
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::unique.unique(\?.*)?/;
const EDIT_URL = /\/admin\/content-manager\/collection-types\/api::unique.unique\/[^/]+(\?.*)?/;
const clickSave = async (page) => {
await page.getByRole('button', { name: 'Save' }).isEnabled();
await page.getByRole('tab', { name: 'Draft' }).click();
await page.getByRole('button', { name: 'Save' }).click();
};
const CREATE_URL =
/\/admin\/content-manager\/collection-types\/api::unique.unique\/create(\?.*)?/;
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::unique.unique(\?.*)?/;
const EDIT_URL = /\/admin\/content-manager\/collection-types\/api::unique.unique\/[^/]+(\?.*)?/;
const extraComponentNavigation = async (field: Field, page: Page) => {
if ('component' in field) {
const isSingle = field.component.isSingle;
// This opens up the component UI so we can access the field we are
// testing against
if (isSingle) {
await page.getByRole('button', { name: 'No entry yet. Click on the' }).first().click();
await page.getByRole('button', { name: 'No entry yet. Click on the' }).first().click();
} else {
await page.getByRole('button', { name: 'No entry yet. Click on the' }).nth(1).click();
await page
.getByLabel('', { exact: true })
.getByRole('button', { name: 'No entry yet. Click on the' })
.click();
}
}
};
/**
* @note the unique content type is set up with every type of document level unique field.
* We are testing that uniqueness is enforced for these fields across all entries of a content type in the same locale.
*/
FIELDS_TO_TEST.forEach((field) => {
test(`A user should not be able to duplicate the ${field.name} document field value in the same content type and locale. Validation should not happen across locales`, async ({
const isComponent = 'component' in field;
const isSingleComponentField = isComponent && field.component.isSingle;
const isRepeatableComponentField = isComponent && !field.component.isSingle;
let fieldDescription = 'scalar field';
if (isComponent) {
fieldDescription = isSingleComponentField
? 'single component field'
: 'repeatable component field';
}
test(`A user should not be able to duplicate the ${field.name} ${fieldDescription} value in the same content type and dimensions (locale + publication state).`, async ({
page,
}) => {
await page.getByRole('link', { name: 'Create new entry' }).first().click();
@ -56,9 +162,31 @@ test.describe('Uniqueness', () => {
/**
* Now we're in the edit view. The content within each entry will be valid from the previous test run.
*/
const fieldRole = 'role' in field ? field.role : 'textbox';
await extraComponentNavigation(field, page);
await page.getByRole(fieldRole, { name: field.name }).fill(field.value);
if (isRepeatableComponentField) {
// Add another entry to the repeatable component in this entry that
// shares the same value as the first entry. This should trigger a
// validation error
await page.getByRole('button', { name: 'Add an entry' }).click();
await page
.getByRole('region')
.getByRole('button', { name: 'No entry yet. Click on the' })
.click();
await page.getByRole(fieldRole, { name: field.name }).fill(field.value);
await clickSave(page);
await expect(page.getByText('Warning:2 errors occurred')).toBeVisible();
await expect(page.getByText('This attribute must be unique')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).nth(1).click();
}
await clickSave(page);
await findAndClose(page, 'Saved document');
@ -72,6 +200,7 @@ test.describe('Uniqueness', () => {
await page.waitForURL(CREATE_URL);
await extraComponentNavigation(field, page);
await page.getByRole(fieldRole, { name: field.name }).fill(field.value);
await clickSave(page);
@ -103,6 +232,7 @@ test.describe('Uniqueness', () => {
await page.waitForURL(EDIT_URL);
await extraComponentNavigation(field, page);
await page.getByRole(fieldRole, { name: field.name }).fill(field.value);
await clickSave(page);