diff --git a/packages/core/content-type-builder/server/controllers/validation/__tests__/content-type.test.js b/packages/core/content-type-builder/server/controllers/validation/__tests__/content-type.test.js index 6d8ae1e60b..7a66f03453 100644 --- a/packages/core/content-type-builder/server/controllers/validation/__tests__/content-type.test.js +++ b/packages/core/content-type-builder/server/controllers/validation/__tests__/content-type.test.js @@ -64,11 +64,13 @@ describe('Content type validator', () => { }); }); - describe('Prevents use of names without plural form', () => { - test('Throws when using name without plural form', async () => { + describe('Prevents use of same singularName and pluralName', () => { + test('Throws when using same singularName and pluralName', async () => { const data = { contentType: { - name: 'news', + displayName: 'news', + singularName: 'news', + pluralName: 'news', attributes: { title: { type: 'string', @@ -79,7 +81,7 @@ describe('Content type validator', () => { await validateContentTypeInput(data).catch(err => { expect(err).toMatchObject({ - 'contentType.name': [expect.stringMatching('cannot be pluralized')], + contentType: [expect.stringMatching('singularName and pluralName should be different')], }); }); }); @@ -89,7 +91,9 @@ describe('Content type validator', () => { test('Can use custom keys', async () => { const input = { contentType: { - name: 'test', + displayName: 'test', + singularName: 'test', + pluralName: 'tests', attributes: { views: { type: 'integer', @@ -115,7 +119,9 @@ describe('Content type validator', () => { test('Deletes empty defaults', async () => { const data = { contentType: { - name: 'test', + displayName: 'test', + singularName: 'test', + pluralName: 'tests', attributes: { slug: { type: 'string', @@ -161,7 +167,9 @@ describe('Content type validator', () => { test('Deleted UID target fields are removed from input data', async () => { const data = { contentType: { - name: 'test', + displayName: 'test', + singularName: 'test', + pluralName: 'tests', attributes: { slug: { type: 'uid', @@ -181,7 +189,9 @@ describe('Content type validator', () => { test('Can use custom keys', async () => { const input = { contentType: { - name: 'test', + displayName: 'test', + singularName: 'test', + pluralName: 'tests', attributes: { views: { type: 'integer', diff --git a/packages/core/content-type-builder/server/controllers/validation/content-type.js b/packages/core/content-type-builder/server/controllers/validation/content-type.js index 7588a5b1ae..3d6380ee04 100644 --- a/packages/core/content-type-builder/server/controllers/validation/content-type.js +++ b/packages/core/content-type-builder/server/controllers/validation/content-type.js @@ -46,26 +46,32 @@ const createContentTypeSchema = (data, { isEdition = false } = {}) => { const kind = _.get(data, 'contentType.kind', typeKinds.COLLECTION_TYPE); const contentTypeSchema = createSchema(VALID_TYPES, VALID_RELATIONS[kind] || [], { modelType: modelTypes.CONTENT_TYPE, - }).shape({ - displayName: yup - .string() - .min(1) - .required(), - singularName: yup - .string() - .min(1) - .test(alreadyUsedContentTypeName(isEdition)) - .test(forbiddenContentTypeNameValidator()) - .isKebabCase() - .required(), - pluralName: yup - .string() - .min(1) - .test(alreadyUsedContentTypeName(isEdition)) - .test(forbiddenContentTypeNameValidator()) - .isKebabCase() - .required(), - }); + }) + .shape({ + displayName: yup + .string() + .min(1) + .required(), + singularName: yup + .string() + .min(1) + .test(alreadyUsedContentTypeName(isEdition)) + .test(forbiddenContentTypeNameValidator()) + .isKebabCase() + .required(), + pluralName: yup + .string() + .min(1) + .test(alreadyUsedContentTypeName(isEdition)) + .test(forbiddenContentTypeNameValidator()) + .isKebabCase() + .required(), + }) + .test( + 'singularName-not-equal-pluralName', + '${path}: singularName and pluralName should be different', + value => value.singularName !== value.pluralName + ); return yup .object({ diff --git a/packages/core/content-type-builder/server/services/__tests__/__snapshots__/content-types.test.js.snap b/packages/core/content-type-builder/server/services/__tests__/__snapshots__/content-types.test.js.snap index 07b6c4e80c..9aadae611f 100644 --- a/packages/core/content-type-builder/server/services/__tests__/__snapshots__/content-types.test.js.snap +++ b/packages/core/content-type-builder/server/services/__tests__/__snapshots__/content-types.test.js.snap @@ -12,15 +12,17 @@ Object { }, "collectionName": "tests", "description": "My description", + "displayName": "My name", "draftAndPublish": false, "kind": "singleType", - "name": "My name", "pluginOptions": Object { "content-manager": Object { "visible": true, }, }, + "pluralName": "my-names", "restrictRelationsTo": null, + "singularName": "my-name", "visible": true, }, "uid": "test-uid", diff --git a/packages/core/content-type-builder/server/services/__tests__/content-types.test.js b/packages/core/content-type-builder/server/services/__tests__/content-types.test.js index e30d73f4e5..67d08d3c98 100644 --- a/packages/core/content-type-builder/server/services/__tests__/content-types.test.js +++ b/packages/core/content-type-builder/server/services/__tests__/content-types.test.js @@ -12,6 +12,8 @@ describe('Content types service', () => { collectionName: 'tests', info: { displayName: 'My name', + singularName: 'my-name', + pluralName: 'my-names', description: 'My description', }, options: { diff --git a/packages/core/content-type-builder/tests/__snapshots__/content-types.test.e2e.js.snap b/packages/core/content-type-builder/tests/__snapshots__/collection-type.test.e2e.js.snap similarity index 64% rename from packages/core/content-type-builder/tests/__snapshots__/content-types.test.e2e.js.snap rename to packages/core/content-type-builder/tests/__snapshots__/collection-type.test.e2e.js.snap index 1b92eafa6c..c575d6ab3a 100644 --- a/packages/core/content-type-builder/tests/__snapshots__/content-types.test.e2e.js.snap +++ b/packages/core/content-type-builder/tests/__snapshots__/collection-type.test.e2e.js.snap @@ -59,38 +59,3 @@ Object { }, } `; - -exports[`Content Type Builder - Content types Single Types Get single type returns full schema and information 1`] = ` -Object { - "data": Object { - "apiID": "test-single-type", - "schema": Object { - "attributes": Object { - "title": Object { - "pluginOptions": Object { - "i18n": Object { - "localized": true, - }, - }, - "type": "string", - }, - }, - "collectionName": "test_single_types", - "description": "", - "displayName": "Test Single Type", - "draftAndPublish": false, - "kind": "singleType", - "pluginOptions": Object { - "i18n": Object { - "localized": true, - }, - }, - "pluralName": "test-single-types", - "restrictRelationsTo": null, - "singularName": "test-single-type", - "visible": true, - }, - "uid": "api::test-single-type.test-single-type", - }, -} -`; diff --git a/packages/core/content-type-builder/tests/__snapshots__/single-type.test.e2e.js.snap b/packages/core/content-type-builder/tests/__snapshots__/single-type.test.e2e.js.snap new file mode 100644 index 0000000000..6e0f48d9af --- /dev/null +++ b/packages/core/content-type-builder/tests/__snapshots__/single-type.test.e2e.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Content Type Builder - Content types Single Types Get single type returns full schema and information 1`] = ` +Object { + "data": Object { + "apiID": "test-single-type", + "schema": Object { + "attributes": Object { + "title": Object { + "pluginOptions": Object { + "i18n": Object { + "localized": true, + }, + }, + "type": "string", + }, + }, + "collectionName": "test_single_types", + "description": "", + "displayName": "Test Single Type", + "draftAndPublish": false, + "kind": "singleType", + "pluginOptions": Object { + "i18n": Object { + "localized": true, + }, + }, + "pluralName": "test-single-types", + "restrictRelationsTo": null, + "singularName": "test-single-type", + "visible": true, + }, + "uid": "api::test-single-type.test-single-type", + }, +} +`; diff --git a/packages/core/content-type-builder/tests/collection-type.test.e2e.js b/packages/core/content-type-builder/tests/collection-type.test.e2e.js new file mode 100644 index 0000000000..bb34d4e9c2 --- /dev/null +++ b/packages/core/content-type-builder/tests/collection-type.test.e2e.js @@ -0,0 +1,277 @@ +/** + * Integration test for the content-type-builder content types management apis + */ +'use strict'; + +const { createStrapiInstance } = require('../../../../test/helpers/strapi'); +const { createAuthRequest } = require('../../../../test/helpers/request'); +const modelsUtils = require('../../../../test/helpers/models'); + +let strapi; +let rq; + +const restart = async () => { + await strapi.destroy(); + strapi = await createStrapiInstance(); + rq = await createAuthRequest({ strapi }); +}; + +describe('Content Type Builder - Content types', () => { + beforeAll(async () => { + strapi = await createStrapiInstance(); + rq = await createAuthRequest({ strapi }); + }); + + afterEach(async () => { + await restart(); + }); + + afterAll(async () => { + const modelsUIDs = [ + 'api::test-collection-type.test-collection-type', + 'api::ct-with-dp.ct-with-dp', + 'api::kebab-case.kebab-case', + 'api::my2space.my2space', + 'api::my-3-space.my-3-space', + ]; + + await modelsUtils.cleanupModels(modelsUIDs, { strapi }); + await modelsUtils.deleteContentTypes(modelsUIDs, { strapi }); + + await strapi.destroy(); + }); + + describe('Collection Types', () => { + const testCollectionTypeUID = 'api::test-collection-type.test-collection-type'; + const ctWithDpUID = 'api::ct-with-dp.ct-with-dp'; + + test('Successful creation of a collection type', async () => { + const res = await rq({ + method: 'POST', + url: '/content-type-builder/content-types', + body: { + contentType: { + displayName: 'Test Collection Type', + singularName: 'test-collection-type', + pluralName: 'test-collection-types', + pluginOptions: { + i18n: { + localized: true, + }, + }, + attributes: { + title: { + type: 'string', + pluginOptions: { + i18n: { + localized: true, + }, + }, + }, + }, + }, + }, + }); + + expect(res.statusCode).toBe(201); + expect(res.body).toEqual({ + data: { + uid: testCollectionTypeUID, + }, + }); + }); + + test('Get collection type returns full schema and information', async () => { + const res = await rq({ + method: 'GET', + url: `/content-type-builder/content-types/${testCollectionTypeUID}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchSnapshot(); + }); + + test('Successfull creation of a collection type with draftAndPublish enabled', async () => { + const res = await rq({ + method: 'POST', + url: '/content-type-builder/content-types', + body: { + contentType: { + displayName: 'CT with DP', + singularName: 'ct-with-dp', + pluralName: 'ct-with-dps', + draftAndPublish: true, + attributes: { + title: { + type: 'string', + }, + }, + }, + }, + }); + + expect(res.statusCode).toBe(201); + expect(res.body).toEqual({ + data: { + uid: ctWithDpUID, + }, + }); + }); + + test('Get collection type returns full schema and informations with draftAndPublish', async () => { + const res = await rq({ + method: 'GET', + url: `/content-type-builder/content-types/${ctWithDpUID}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchSnapshot(); + }); + + test('Cannot use same string for singularName and pluralName', async () => { + const res = await rq({ + method: 'POST', + url: '/content-type-builder/content-types', + body: { + contentType: { + displayName: 'same string', + singularName: 'same-string', + pluralName: 'same-string', + draftAndPublish: true, + attributes: { + title: { + type: 'string', + }, + }, + }, + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: { + contentType: ['contentType: singularName and pluralName should be different'], + }, + }); + }); + + test('displayNamen singularName and pluralName are required', async () => { + const res = await rq({ + method: 'POST', + url: '/content-type-builder/content-types', + body: { + contentType: { + draftAndPublish: true, + attributes: { + title: { + type: 'string', + }, + }, + }, + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: { + contentType: ['contentType: singularName and pluralName should be different'], + 'contentType.displayName': ['contentType.displayName is a required field'], + 'contentType.singularName': ['contentType.singularName is a required field'], + 'contentType.pluralName': ['contentType.pluralName is a required field'], + }, + }); + }); + + test('Can edit displayName but singularName and pluralName are ignored', async () => { + const uid = 'api::ct-with-dp.ct-with-dp'; + let res = await rq({ + method: 'PUT', + url: `/content-type-builder/content-types/${uid}`, + body: { + contentType: { + displayName: 'new displayName', + singularName: 'ct-with-dp-new', + pluralName: 'ct-with-dps-new', + draftAndPublish: true, + attributes: { + title: { + type: 'string', + }, + }, + }, + }, + }); + expect(res.statusCode).toBe(201); + + await restart(); + + res = await rq({ + method: 'GET', + url: `/content-type-builder/content-types/${uid}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + data: { + uid, + schema: { + displayName: 'new displayName', + singularName: 'ct-with-dp', // no change + pluralName: 'ct-with-dps', + draftAndPublish: true, + attributes: { + title: { + type: 'string', + }, + }, + }, + }, + }); + }); + + test.each([ + ['kebab-case', 'kebab-cases', true], + ['Kebab-case', 'Kebab-cases', false], + ['kebab case', 'kebab cases', false], + ['kebabCase', 'kebabCases', false], + ['kebab@case', 'kebab@cases', false], + ['my2space', 'my2spaces', true], + ['2myspace', '2myspaces', false], + ['my-3-space', 'my-3-spaces', true], + ])('Are "%s" and "%s" valid: %s', async (singularName, pluralName, isExpectedValid) => { + const res = await rq({ + method: 'POST', + url: '/content-type-builder/content-types', + body: { + contentType: { + displayName: 'same string', + singularName, + pluralName, + draftAndPublish: true, + attributes: { + title: { + type: 'string', + }, + }, + }, + }, + }); + + if (isExpectedValid) { + expect(res.statusCode).toBe(201); + } else { + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: { + 'contentType.pluralName': [ + 'contentType.pluralName is not in kebab case (an-example-of-kebab-case)', + ], + 'contentType.singularName': [ + 'contentType.singularName is not in kebab case (an-example-of-kebab-case)', + ], + }, + }); + } + }); + }); +}); diff --git a/packages/core/content-type-builder/tests/content-types.test.e2e.js b/packages/core/content-type-builder/tests/single-type.test.e2e.js similarity index 67% rename from packages/core/content-type-builder/tests/content-types.test.e2e.js rename to packages/core/content-type-builder/tests/single-type.test.e2e.js index 63a5fcfb5f..7c63f31f2f 100644 --- a/packages/core/content-type-builder/tests/content-types.test.e2e.js +++ b/packages/core/content-type-builder/tests/single-type.test.e2e.js @@ -28,10 +28,8 @@ describe('Content Type Builder - Content types', () => { afterAll(async () => { const modelsUIDs = [ - 'api::test-collection-type.test-collection-type', 'api::test-collection.test-collection', 'api::test-single-type.test-single-type', - 'api::ct-with-dp.ct-with-dp', ]; await modelsUtils.cleanupModels(modelsUIDs, { strapi }); @@ -40,94 +38,6 @@ describe('Content Type Builder - Content types', () => { await strapi.destroy(); }); - describe('Collection Types', () => { - const testCollectionTypeUID = 'api::test-collection-type.test-collection-type'; - const ctWithDpUID = 'api::ct-with-dp.ct-with-dp'; - - test('Successful creation of a collection type', async () => { - const res = await rq({ - method: 'POST', - url: '/content-type-builder/content-types', - body: { - contentType: { - displayName: 'Test Collection Type', - singularName: 'test-collection-type', - pluralName: 'test-collection-types', - pluginOptions: { - i18n: { - localized: true, - }, - }, - attributes: { - title: { - type: 'string', - pluginOptions: { - i18n: { - localized: true, - }, - }, - }, - }, - }, - }, - }); - - expect(res.statusCode).toBe(201); - expect(res.body).toEqual({ - data: { - uid: testCollectionTypeUID, - }, - }); - }); - - test('Get collection type returns full schema and information', async () => { - const res = await rq({ - method: 'GET', - url: `/content-type-builder/content-types/${testCollectionTypeUID}`, - }); - - expect(res.statusCode).toBe(200); - expect(res.body).toMatchSnapshot(); - }); - - test('Successfull creation of a collection type with draftAndPublish enabled', async () => { - const res = await rq({ - method: 'POST', - url: '/content-type-builder/content-types', - body: { - contentType: { - displayName: 'CT with DP', - singularName: 'ct-with-dp', - pluralName: 'ct-with-dps', - draftAndPublish: true, - attributes: { - title: { - type: 'string', - }, - }, - }, - }, - }); - - expect(res.statusCode).toBe(201); - expect(res.body).toEqual({ - data: { - uid: ctWithDpUID, - }, - }); - }); - - test('Get collection type returns full schema and informations with draftAndPublish', async () => { - const res = await rq({ - method: 'GET', - url: `/content-type-builder/content-types/${ctWithDpUID}`, - }); - - expect(res.statusCode).toBe(200); - expect(res.body).toMatchSnapshot(); - }); - }); - describe('Single Types', () => { const singleTypeUID = 'api::test-single-type.test-single-type'; @@ -259,12 +169,8 @@ describe('Content Type Builder - Content types', () => { expect(updateRes.statusCode).toBe(400); expect(updateRes.body.error).toMatch('multiple entries in DB'); }); - }); - describe('Private relation field', () => { - const singleTypeUID = 'api::test-single-type.test-single-type'; - - test('should add a relation field', async () => { + test('Should add a relation field', async () => { const res = await rq({ method: 'PUT', url: `/content-type-builder/content-types/${singleTypeUID}`, @@ -294,7 +200,7 @@ describe('Content Type Builder - Content types', () => { }); }); - test('should contain a private relation field', async () => { + test('Should contain a private relation field', async () => { const res = await rq({ method: 'GET', url: `/content-type-builder/content-types/${singleTypeUID}`, diff --git a/packages/core/strapi/lib/core/loaders/apis.js b/packages/core/strapi/lib/core/loaders/apis.js index 7a9164ebf2..5c6bcc27be 100644 --- a/packages/core/strapi/lib/core/loaders/apis.js +++ b/packages/core/strapi/lib/core/loaders/apis.js @@ -4,8 +4,10 @@ const { join, extname, basename } = require('path'); const { existsSync } = require('fs-extra'); const _ = require('lodash'); const fse = require('fs-extra'); +const { isKebabCase } = require('@strapi/utils'); -const normalizeName = _.kebabCase; +// to handle names with numbers in it we first check is it is already in kebabCase +const normalizeName = name => (isKebabCase(name) ? name : _.kebabCase(name)); const DEFAULT_CONTENT_TYPE = { schema: {},