mirror of
https://github.com/strapi/strapi.git
synced 2025-12-26 14:44:31 +00:00
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:
parent
cefa185d29
commit
92fb9e9b23
@ -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
|
||||
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
100
packages/core/admin/admin/src/components/tests/Form.test.tsx
Normal file
100
packages/core/admin/admin/src/components/tests/Form.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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 }));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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'),
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
tests/e2e/app-template/template/src/components/unique/all.json
Normal file
100
tests/e2e/app-template/template/src/components/unique/all.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user