diff --git a/examples/getstarted/api/restaurant/models/Restaurant.settings.json b/examples/getstarted/api/restaurant/models/Restaurant.settings.json index 2ab966e87f..26ba49bf47 100755 --- a/examples/getstarted/api/restaurant/models/Restaurant.settings.json +++ b/examples/getstarted/api/restaurant/models/Restaurant.settings.json @@ -8,10 +8,7 @@ "options": { "draftAndPublish": true, "increments": true, - "timestamps": [ - "created_at", - "updated_at" - ], + "timestamps": ["created_at", "updated_at"], "comment": "" }, "pluginOptions": { @@ -41,13 +38,7 @@ } }, "price_range": { - "enum": [ - "very_cheap", - "cheap", - "average", - "expensive", - "very_expensive" - ], + "enum": ["very_cheap", "cheap", "average", "expensive", "very_expensive"], "type": "enumeration", "pluginOptions": { "i18n": { @@ -169,15 +160,6 @@ "localized": true } } - }, - "parent": { - "collection": "restaurant", - "via": "children", - "dominant": true - }, - "children": { - "collection": "restaurant", - "via": "parent" } } } diff --git a/packages/strapi-plugin-i18n/config/functions/bootstrap.js b/packages/strapi-plugin-i18n/config/functions/bootstrap.js index 0b8d62c51e..9d68611849 100644 --- a/packages/strapi-plugin-i18n/config/functions/bootstrap.js +++ b/packages/strapi-plugin-i18n/config/functions/bootstrap.js @@ -16,7 +16,8 @@ module.exports = async () => { const { actionProvider } = strapi.admin.services.permission; actionProvider.register(actions); - getService('entity-service-decorator').decorate(); + const { decorator } = getService('entity-service-decorator'); + strapi.entityService.decorate(decorator); await getService('locales').initDefaultLocale(); diff --git a/packages/strapi-plugin-i18n/services/__tests__/entity-service-decorator.test.js b/packages/strapi-plugin-i18n/services/__tests__/entity-service-decorator.test.js new file mode 100644 index 0000000000..5541b869e4 --- /dev/null +++ b/packages/strapi-plugin-i18n/services/__tests__/entity-service-decorator.test.js @@ -0,0 +1,240 @@ +'use strict'; + +jest.mock('../localizations', () => { + return { + syncLocalizations: jest.fn(async () => {}), + updateNonLocalizedFields: jest.fn(async () => {}), + }; +}); + +const { decorator } = require('../entity-service-decorator'); +const { syncLocalizations, updateNonLocalizedFields } = require('../localizations'); + +const model = { + pluginOptions: { + i18n: { + localized: true, + }, + }, +}; + +const nonLocalizedModel = { + pluginOptions: { + i18n: { + localized: false, + }, + }, +}; + +const models = { + 'test-model': model, + 'non-localized-model': nonLocalizedModel, +}; + +describe('Entity service decorator', () => { + beforeAll(() => { + global.strapi = { + query() { + return { + create() {}, + update() {}, + }; + }, + db: { + getModel(uid) { + return models[uid || 'test-model']; + }, + }, + store: () => ({ get: () => 'en' }), + }; + }); + + beforeEach(() => { + syncLocalizations.mockClear(); + updateNonLocalizedFields.mockClear(); + }); + + describe('wrapOptions', () => { + test('Calls original wrapOptions', async () => { + const defaultService = { + wrapOptions: jest.fn(() => Promise.resolve('li')), + }; + + const service = decorator(defaultService); + + const input = { populate: ['test'] }; + await service.wrapOptions(input, { model: 'test-model' }); + + expect(defaultService.wrapOptions).toHaveBeenCalledWith(input, { model: 'test-model' }); + }); + + test('Does not wrap options if model is not localized', async () => { + const defaultService = { + wrapOptions: jest.fn(opts => Promise.resolve(opts)), + }; + const service = decorator(defaultService); + + const input = { populate: ['test'] }; + const output = await service.wrapOptions(input, { model: 'non-localized-model' }); + + expect(output).toStrictEqual(input); + }); + + test('does not change non params options', async () => { + const defaultService = { + wrapOptions: jest.fn(opts => Promise.resolve(opts)), + }; + const service = decorator(defaultService); + + const input = { populate: ['test'] }; + const output = await service.wrapOptions(input, { model: 'test-model' }); + + expect(output.populate).toStrictEqual(input.populate); + }); + + test('Adds locale param', async () => { + const defaultService = { + wrapOptions: jest.fn(opts => Promise.resolve(opts)), + }; + const service = decorator(defaultService); + + const input = { populate: ['test'] }; + const output = await service.wrapOptions(input, { model: 'test-model' }); + + expect(output).toMatchObject({ params: { locale: 'en' } }); + }); + + test('Replaces _locale param', async () => { + const defaultService = { + wrapOptions: jest.fn(opts => Promise.resolve(opts)), + }; + const service = decorator(defaultService); + + const input = { + params: { + _locale: 'fr', + }, + populate: ['test'], + }; + const output = await service.wrapOptions(input, { model: 'test-model' }); + + expect(output).toMatchObject({ params: { locale: 'fr' } }); + }); + }); + + describe('create', () => { + test('Calls original create', async () => { + const entry = { + id: 1, + }; + + const defaultService = { + create: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { data: { title: 'title ' } }; + await service.create(input, { model: 'test-model' }); + + expect(defaultService.create).toHaveBeenCalledWith(input, { model: 'test-model' }); + }); + + test('Calls syncLocalizations if model is localized', async () => { + const entry = { + id: 1, + localizations: [{ id: 2 }], + }; + + const defaultService = { + create: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { data: { title: 'title ' } }; + await service.create(input, { model: 'test-model' }); + + expect(defaultService.create).toHaveBeenCalledWith(input, { model: 'test-model' }); + expect(syncLocalizations).toHaveBeenCalledWith(entry, { model }); + }); + + test('Skip processing if model is not localized', async () => { + const entry = { + id: 1, + localizations: [{ id: 2 }], + }; + + const defaultService = { + create: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { data: { title: 'title ' } }; + const output = await service.create(input, { model: 'non-localized-model' }); + + expect(defaultService.create).toHaveBeenCalledWith(input, { model: 'non-localized-model' }); + expect(syncLocalizations).not.toHaveBeenCalled(); + expect(output).toStrictEqual(entry); + }); + }); + + describe('update', () => { + test('Calls original update', async () => { + const entry = { + id: 1, + }; + + const defaultService = { + update: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { params: { id: 1 }, data: { title: 'title ' } }; + await service.update(input, { model: 'test-model' }); + + expect(defaultService.update).toHaveBeenCalledWith(input, { model: 'test-model' }); + }); + + test('Calls updateNonLocalizedFields if model is localized', async () => { + const entry = { + id: 1, + localizations: [{ id: 2 }], + }; + + const defaultService = { + update: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { params: { id: 1 }, data: { title: 'title ' } }; + const output = await service.update(input, { model: 'test-model' }); + + expect(defaultService.update).toHaveBeenCalledWith(input, { model: 'test-model' }); + expect(updateNonLocalizedFields).toHaveBeenCalledWith(entry, { model }); + expect(output).toStrictEqual(entry); + }); + + test('Skip processing if model is not localized', async () => { + const entry = { + id: 1, + localizations: [{ id: 2 }], + }; + + const defaultService = { + update: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { params: { id: 1 }, data: { title: 'title ' } }; + await service.update(input, { model: 'non-localized-model' }); + + expect(defaultService.update).toHaveBeenCalledWith(input, { model: 'non-localized-model' }); + expect(updateNonLocalizedFields).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/strapi-plugin-i18n/services/entity-service-decorator.js b/packages/strapi-plugin-i18n/services/entity-service-decorator.js index 1adbeec229..806ee2a0e1 100644 --- a/packages/strapi-plugin-i18n/services/entity-service-decorator.js +++ b/packages/strapi-plugin-i18n/services/entity-service-decorator.js @@ -1,20 +1,68 @@ 'use strict'; -const { getService } = require('../utils'); +const { has, omit } = require('lodash/fp'); -module.exports = { - decorate() { - strapi.entityService.decorate(entityServiceDecorator); - }, +const { getDefaultLocale } = require('./locales'); +const { isLocalized } = require('./content-types'); +const { syncLocalizations, updateNonLocalizedFields } = require('./localizations'); + +const LOCALE_QUERY_FILTER = '_locale'; + +/** + * Adds default locale or replaces _locale by locale in query params + * @param {object} params - query params + */ +const wrapParams = async (params = {}) => { + if (has(LOCALE_QUERY_FILTER, params)) { + return { + ...omit(LOCALE_QUERY_FILTER, params), + locale: params[LOCALE_QUERY_FILTER], + }; + } + + return { + ...params, + locale: await getDefaultLocale(), + }; }; -const entityServiceDecorator = service => ({ - async create(params, ctx) { +/** + * Decorates the entity service with I18N business logic + * @param {object} service - entity service + */ +const decorator = service => ({ + /** + * Wraps query options. In particular will add default locale to query params + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used + */ + async wrapOptions(opts = {}, ctx) { + const wrappedOptions = await service.wrapOptions(opts, ctx); const model = strapi.db.getModel(ctx.model); - const entry = await service.create(params, ctx); - if (getService('content-types').isLocalized(model)) { - await getService('localizations').syncLocalizations(entry, { model }); + if (!isLocalized(model)) { + return wrappedOptions; + } + + return { + ...wrappedOptions, + params: await wrapParams(wrappedOptions.params), + }; + }, + + /** + * Creates an entry & make links between it and its related localizaionts + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used + */ + async create(opts, ctx) { + const model = strapi.db.getModel(ctx.model); + const entry = await service.create(opts, ctx); + + if (isLocalized(model)) { + await syncLocalizations(entry, { model }); } return entry; @@ -22,17 +70,23 @@ const entityServiceDecorator = service => ({ /** * Updates an entry & update related localizations fields - * @param {obj} params - query params - * @param {obj} ctx - query context (model) + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ - async update(params, ctx) { + async update(opts, ctx) { const model = strapi.db.getModel(ctx.model); - const entry = await service.update(params, ctx); + const entry = await service.update(opts, ctx); - if (getService('content-types').isLocalized(model)) { - await getService('localizations').updateNonLocalizedFields(entry, { model }); + if (isLocalized(model)) { + await updateNonLocalizedFields(entry, { model }); } return entry; }, }); + +module.exports = { + decorator, + wrapParams, +}; diff --git a/packages/strapi/lib/services/entity-service.js b/packages/strapi/lib/services/entity-service.js index 351c890578..294c7565d1 100644 --- a/packages/strapi/lib/services/entity-service.js +++ b/packages/strapi/lib/services/entity-service.js @@ -43,11 +43,25 @@ const createDefaultImplementation = ({ db, eventHub, entityValidator }) => ({ uploadFiles, /** - * Promise to fetch all records - * - * @return {Promise} + * Returns default opt + * it is async so decorators can do async processing + * @param {object} params - query params to extend + * @param {object=} ctx - Query context + * @param {object} ctx.model - Model that is being used */ - async find({ params, populate }, { model }) { + async wrapOptions(options = {}) { + return options; + }, + + /** + * Returns a list of entries + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used + */ + async find(opts, { model }) { + const { params, populate } = await this.wrapOptions(opts, { model }); + const { kind } = db.getModel(model); // return first element and ignore filters @@ -59,41 +73,63 @@ const createDefaultImplementation = ({ db, eventHub, entityValidator }) => ({ return db.query(model).find(params, populate); }, - findPage({ params, populate }, { model }) { + /** + * Returns a paginated list of entries + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used + */ + async findPage(opts, { model }) { + const { params, populate } = await this.wrapOptions(opts, { model }); + return db.query(model).findPage(params, populate); }, - findWithRelationCounts({ params, populate }, { model }) { + /** + * Returns a list of entries with relation counters + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used + */ + async findWithRelationCounts(opts, { model }) { + const { params, populate } = await this.wrapOptions(opts, { model }); + return db.query(model).findWithRelationCounts(params, populate); }, /** - * Promise to fetch record - * - * @return {Promise} + * Returns one entry + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ + async findOne(opts, { model }) { + const { params, populate } = await this.wrapOptions(opts, { model }); - findOne({ params, populate }, { model }) { return db.query(model).findOne(params, populate); }, /** - * Promise to count record - * - * @return {Promise} + * Returns a count of entries + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ + async count(opts, { model }) { + const { params } = await this.wrapOptions(opts, { model }); - count({ params }, { model }) { return db.query(model).count(params); }, /** - * Promise to add record - * - * @return {Promise} + * Creates & returns a new entry + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ + async create(opts, { model }) { + const { data, files } = await this.wrapOptions(opts, { model }); - async create({ data, files }, { model }) { const modelDef = db.getModel(model); if (modelDef.kind === 'singleType') { @@ -124,12 +160,14 @@ const createDefaultImplementation = ({ db, eventHub, entityValidator }) => ({ }, /** - * Promise to edit record - * - * @return {Promise} + * Updates & returns an existing entry + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ + async update(opts, { model }) { + const { params, data, files } = await this.wrapOptions(opts, { model }); - async update({ params, data, files }, { model }) { const modelDef = db.getModel(model); const existingEntry = await db.query(model).findOne(params); @@ -155,12 +193,14 @@ const createDefaultImplementation = ({ db, eventHub, entityValidator }) => ({ }, /** - * Promise to delete a record - * - * @return {Promise} + * Deletes & returns the entry that was deleted + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ + async delete(opts, { model }) { + const { params } = await this.wrapOptions(opts, { model }); - async delete({ params }, { model }) { const entry = await db.query(model).delete(params); const modelDef = db.getModel(model); @@ -173,29 +213,50 @@ const createDefaultImplementation = ({ db, eventHub, entityValidator }) => ({ }, /** - * Promise to search records - * - * @return {Promise} + * Returns a list of matching entries + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ + async search(opts, { model }) { + const { params, populate } = await this.wrapOptions(opts, { model }); - search({ params, populate }, { model }) { return db.query(model).search(params, populate); }, - searchWithRelationCounts({ params, populate }, { model }) { + /** + * Returns a list of matching entries with relations counters + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used + */ + async searchWithRelationCounts(opts, { model }) { + const { params, populate } = await this.wrapOptions(opts, { model }); + return db.query(model).searchWithRelationCounts(params, populate); }, - searchPage({ params, populate }, { model }) { + /** + * Returns a paginated list of matching entries + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used + */ + async searchPage(opts, { model }) { + const { params, populate } = await this.wrapOptions(opts, { model }); + return db.query(model).searchPage(params, populate); }, /** * Promise to count searched records - * - * @return {Promise} + * @param {object} opts - Query options object (params, data, files, populate) + * @param {object} ctx - Query context + * @param {object} ctx.model - Model that is being used */ - countSearch({ params }, { model }) { + async countSearch(opts, { model }) { + const { params } = await this.wrapOptions(opts, { model }); + return db.query(model).countSearch(params); }, });