diff --git a/packages/core/content-manager/server/src/controllers/collection-types.ts b/packages/core/content-manager/server/src/controllers/collection-types.ts index de3d65e15cd..29aaed3714b 100644 --- a/packages/core/content-manager/server/src/controllers/collection-types.ts +++ b/packages/core/content-manager/server/src/controllers/collection-types.ts @@ -411,7 +411,7 @@ export default { const { userAbility } = ctx.state; const { model } = ctx.params; const { body } = ctx.request; - const { ids } = body; + const { documentIds } = body; await validateBulkActionInput(body); @@ -422,15 +422,12 @@ export default { return ctx.forbidden(); } - const permissionQuery = await permissionChecker.sanitizedQuery.publish(ctx.query); - const populate = await getService('populate-builder')(model) - .populateFromQuery(permissionQuery) - .populateDeep(Infinity) - .countRelations() - .build(); + const { locale } = getDocumentLocaleAndStatus(body); - const entityPromises = ids.map((id: any) => documentManager.findOne(id, model, { populate })); - const entities = await Promise.all(entityPromises); + const entityPromises = documentIds.map((documentId: any) => + documentManager.findLocales(documentId, model, { locale, isPublished: false }) + ); + const entities = (await Promise.all(entityPromises)).flat(); for (const entity of entities) { if (!entity) { @@ -442,8 +439,9 @@ export default { } } - // @ts-expect-error - publish many should not return null - const { count } = await documentManager.publishMany(entities, model); + const entitiesIds = entities.map((document) => document.documentId); + + const { count } = await documentManager.publishMany(entitiesIds, model, { locale }); ctx.body = { count }; }, @@ -451,7 +449,7 @@ export default { const { userAbility } = ctx.state; const { model } = ctx.params; const { body } = ctx.request; - const { ids } = body; + const { documentIds } = body; await validateBulkActionInput(body); @@ -462,13 +460,12 @@ export default { return ctx.forbidden(); } - const permissionQuery = await permissionChecker.sanitizedQuery.publish(ctx.query); - const populate = await getService('populate-builder')(model) - .populateFromQuery(permissionQuery) - .build(); + const { locale } = getDocumentLocaleAndStatus(body); - const entityPromises = ids.map((id: any) => documentManager.findOne(id, model, { populate })); - const entities = await Promise.all(entityPromises); + const entityPromises = documentIds.map((documentId: any) => + documentManager.findLocales(documentId, model, { locale, isPublished: true }) + ); + const entities = (await Promise.all(entityPromises)).flat(); for (const entity of entities) { if (!entity) { @@ -480,8 +477,10 @@ export default { } } - // @ts-expect-error - unpublish many should not return null - const { count } = await documentManager.unpublishMany(entities, model); + const entitiesIds = entities.map((document) => document.documentId); + + const { count } = await documentManager.unpublishMany(entitiesIds, model, { locale }); + ctx.body = { count }; }, @@ -587,7 +586,7 @@ export default { const { userAbility } = ctx.state; const { model } = ctx.params; const { query, body } = ctx.request; - const { ids } = body; + const { documentIds } = body; await validateBulkActionInput(body); @@ -598,18 +597,32 @@ export default { return ctx.forbidden(); } - // TODO: fix const permissionQuery = await permissionChecker.sanitizedQuery.delete(query); + const populate = await getService('populate-builder')(model) + .populateFromQuery(permissionQuery) + .build(); - const idsWhereClause = { id: { $in: ids } }; - const params = { - ...permissionQuery, - filters: { - $and: [idsWhereClause].concat(permissionQuery.filters || []), - }, - }; + const { locale } = getDocumentLocaleAndStatus(body); + + const documentLocales = await documentManager.findLocales(documentIds, model, { + populate, + locale, + }); + + if (documentLocales.length === 0) { + return ctx.notFound(); + } + + for (const document of documentLocales) { + if (permissionChecker.cannot.delete(document)) { + return ctx.forbidden(); + } + } + + // We filter out documentsIds that maybe doesn't exist in a specific locale + const localeDocumentsIds = documentLocales.map((document) => document.documentId); - const { count } = await documentManager.deleteMany(params, model); + const { count } = await documentManager.deleteMany(localeDocumentsIds, model, { locale }); ctx.body = { count }; }, @@ -650,8 +663,7 @@ export default { async countManyEntriesDraftRelations(ctx: any) { const { userAbility } = ctx.state; - const ids = ctx.request.query.ids as any; - const locale = ctx.request.query.locale; + const { documentIds, locale } = ctx.request.query; const { model } = ctx.params; const documentManager = getService('document-manager'); @@ -661,21 +673,21 @@ export default { return ctx.forbidden(); } - const entities = await documentManager.findMany( + const documents = await documentManager.findMany( { filters: { - id: ids, + documentId: { $in: documentIds }, }, locale, }, model ); - if (!entities) { + if (!documents) { return ctx.notFound(); } - const number = await documentManager.countManyEntriesDraftRelations(ids, model, locale); + const number = await documentManager.countManyEntriesDraftRelations(documentIds, model, locale); return { data: number, diff --git a/packages/core/content-manager/server/src/controllers/validation/index.ts b/packages/core/content-manager/server/src/controllers/validation/index.ts index 38a7c636217..c8c4a4489df 100644 --- a/packages/core/content-manager/server/src/controllers/validation/index.ts +++ b/packages/core/content-manager/server/src/controllers/validation/index.ts @@ -12,7 +12,7 @@ const kindSchema = yup.string().oneOf(TYPES).nullable(); const bulkActionInputSchema = yup .object({ - ids: yup.array().of(yup.strapiID()).min(1).required(), + documentIds: yup.array().of(yup.strapiID()).min(1).required(), }) .required(); diff --git a/packages/core/content-manager/server/src/services/document-manager.ts b/packages/core/content-manager/server/src/services/document-manager.ts index b8131936cd9..e0e1d3d6efb 100644 --- a/packages/core/content-manager/server/src/services/document-manager.ts +++ b/packages/core/content-manager/server/src/services/document-manager.ts @@ -1,41 +1,21 @@ import { omit, pipe } from 'lodash/fp'; -import { contentTypes, sanitize, errors, pagination } from '@strapi/utils'; +import { contentTypes, errors, pagination } from '@strapi/utils'; import type { Core, Modules, UID } from '@strapi/types'; import { buildDeepPopulate, getDeepPopulate, getDeepPopulateDraftCount } from './utils/populate'; import { sumDraftCounts } from './utils/draft'; -import { ALLOWED_WEBHOOK_EVENTS } from '../constants'; type DocService = Modules.Documents.ServiceInstance; type DocServiceParams = Parameters[0]; export type Document = Modules.Documents.Result; const { ApplicationError } = errors; -const { ENTRY_PUBLISH, ENTRY_UNPUBLISH } = ALLOWED_WEBHOOK_EVENTS; const { PUBLISHED_AT_ATTRIBUTE } = contentTypes.constants; const omitPublishedAtField = omit(PUBLISHED_AT_ATTRIBUTE); const omitIdField = omit('id'); -const emitEvent = async (uid: UID.ContentType, event: string, document: Document) => { - const modelDef = strapi.getModel(uid); - const sanitizedDocument = await sanitize.sanitizers.defaultSanitizeOutput( - { - schema: modelDef, - getModel(uid) { - return strapi.getModel(uid as UID.Schema); - }, - }, - document - ); - - strapi.eventHub.emit(event, { - model: modelDef.modelName, - entry: sanitizedDocument, - }); -}; - const documentManager = ({ strapi }: { strapi: Core.Strapi }) => { return { async findOne( @@ -50,11 +30,12 @@ const documentManager = ({ strapi }: { strapi: Core.Strapi }) => { * Find multiple (or all) locales for a document */ async findLocales( - id: string | undefined, + id: string | string[] | undefined, uid: UID.CollectionType, opts: { populate?: Modules.Documents.Params.Pick; locale?: string | string[] | '*'; + isPublished?: boolean; } ) { // Will look for a specific locale by default @@ -73,6 +54,11 @@ const documentManager = ({ strapi }: { strapi: Core.Strapi }) => { where.locale = opts.locale; } + // Published is passed, so we filter on it, otherwise we don't filter + if (typeof opts.isPublished === 'boolean') { + where.publishedAt = { $notNull: opts.isPublished }; + } + return strapi.db.query(uid).findMany({ populate: opts.populate, where }); }, @@ -167,14 +153,16 @@ const documentManager = ({ strapi }: { strapi: Core.Strapi }) => { }, // FIXME: handle relations - async deleteMany(opts: DocServiceParams<'findMany'>, uid: UID.CollectionType) { - const docs = await strapi.documents(uid).findMany(opts); - - for (const doc of docs) { - await strapi.documents!(uid).delete({ documentId: doc.documentId }); - } + async deleteMany( + documentIds: Modules.Documents.ID[], + uid: UID.CollectionType, + opts: DocServiceParams<'findMany'> + ) { + const deletedEntries = await strapi.db.transaction(async () => { + return Promise.all(documentIds.map(async (id) => this.delete(id, uid, opts))); + }); - return { count: docs.length }; + return { count: deletedEntries.length }; }, async publish( @@ -191,85 +179,48 @@ const documentManager = ({ strapi }: { strapi: Core.Strapi }) => { .then((result) => result?.entries.at(0)); }, - async publishMany(entities: Document[], uid: UID.ContentType) { - if (!entities.length) { - return null; - } - - // Validate entities before publishing, throw if invalid - await Promise.all( - entities.map((document: Document) => { - return strapi.entityValidator.validateEntityCreation( - strapi.getModel(uid), - document, - undefined, - // @ts-expect-error - FIXME: entity here is unnecessary - document - ); - }) - ); - - // Only publish entities without a published_at date - const entitiesToPublish = entities - .filter((doc: Document) => !doc[PUBLISHED_AT_ATTRIBUTE]) - .map((doc: Document) => doc.id); - - const filters = { id: { $in: entitiesToPublish } }; - const data = { [PUBLISHED_AT_ATTRIBUTE]: new Date() }; - const populate = await buildDeepPopulate(uid); - - // Everything is valid, publish - const publishedEntitiesCount = await strapi.db.query(uid).updateMany({ - where: filters, - data, - }); - // Get the updated entities since updateMany only returns the count - const publishedEntities = await strapi.db.query(uid).findMany({ - where: filters, - populate, + async publishMany( + documentIds: Modules.Documents.ID[], + uid: UID.ContentType, + opts: Omit, 'documentId'> = {} as any + ) { + const publishedEntries = await strapi.db.transaction(async () => { + return Promise.all( + documentIds.map((id) => + strapi + .documents(uid) + .publish({ ...opts, documentId: id }) + .then((result) => result?.entries) + ) + ); }); - // Emit the publish event for all updated entities - await Promise.all( - publishedEntities!.map((doc: Document) => emitEvent(uid, ENTRY_PUBLISH, doc)) - ); + + const publishedEntitiesCount = publishedEntries.flat().filter(Boolean).length; // Return the number of published entities - return publishedEntitiesCount; + return { count: publishedEntitiesCount }; }, - async unpublishMany(documents: Document[], uid: UID.CollectionType) { - if (!documents.length) { - return null; - } - - // Only unpublish entities with a published_at date - const entitiesToUnpublish = documents - .filter((doc: Document) => doc[PUBLISHED_AT_ATTRIBUTE]) - .map((doc: Document) => doc.id); - - const filters = { id: { $in: entitiesToUnpublish } }; - const data = { [PUBLISHED_AT_ATTRIBUTE]: null }; - const populate = await buildDeepPopulate(uid); - - // No need to validate, unpublish - const unpublishedEntitiesCount = await strapi.db.query(uid).updateMany({ - where: filters, - data, - }); - - // Get the updated entities since updateMany only returns the count - const unpublishedEntities = await strapi.db.query(uid).findMany({ - where: filters, - populate, + async unpublishMany( + documentIds: Modules.Documents.ID[], + uid: UID.CollectionType, + opts: Omit, 'documentId'> = {} as any + ) { + const unpublishedEntries = await strapi.db.transaction(async () => { + return Promise.all( + documentIds.map((id) => + strapi + .documents(uid) + .unpublish({ ...opts, documentId: id }) + .then((result) => result?.entries) + ) + ); }); - // Emit the unpublish event for all updated entities - await Promise.all( - unpublishedEntities!.map((doc: Document) => emitEvent(uid, ENTRY_UNPUBLISH, doc)) - ); + const unpublishedEntitiesCount = unpublishedEntries.flat().filter(Boolean).length; // Return the number of unpublished entities - return unpublishedEntitiesCount; + return { count: unpublishedEntitiesCount }; }, async unpublish( @@ -315,22 +266,26 @@ const documentManager = ({ strapi }: { strapi: Core.Strapi }) => { return sumDraftCounts(document, uid); }, - async countManyEntriesDraftRelations(ids: number[], uid: UID.CollectionType, locale: string) { + async countManyEntriesDraftRelations( + documentIds: Modules.Documents.ID[], + uid: UID.CollectionType, + locale: string + ) { const { populate, hasRelations } = getDeepPopulateDraftCount(uid); if (!hasRelations) { return 0; } - const entities = await strapi.db.query(uid).findMany({ + const documents = await strapi.documents(uid).findMany({ populate, - where: { - id: { $in: ids }, - ...(locale ? { locale } : {}), + filters: { + documentId: documentIds, }, + locale, }); - const totalNumberDraftRelations: number = entities!.reduce( + const totalNumberDraftRelations: number = documents!.reduce( (count: number, entity: Document) => sumDraftCounts(entity, uid) + count, 0 ); diff --git a/packages/core/content-manager/shared/contracts/collection-types.ts b/packages/core/content-manager/shared/contracts/collection-types.ts index 858b8e8ce81..438265e94e9 100644 --- a/packages/core/content-manager/shared/contracts/collection-types.ts +++ b/packages/core/content-manager/shared/contracts/collection-types.ts @@ -282,7 +282,9 @@ export declare namespace BulkDelete { body: { documentIds: Modules.Documents.ID[]; }; - query: {}; + query: { + locale?: string; + }; } export interface Params { diff --git a/tests/api/core/content-manager/api/basic-dp-compo.test.api.js b/tests/api/core/content-manager/api/basic-dp-compo.test.api.js index 0db84f4fb30..6adfd2e783c 100644 --- a/tests/api/core/content-manager/api/basic-dp-compo.test.api.js +++ b/tests/api/core/content-manager/api/basic-dp-compo.test.api.js @@ -246,9 +246,8 @@ describe('CM API - Basic + compo', () => { // TODO: Validate document is published }); - // TODO: Implement bulk publish - test.skip('Can bulk publish product with compo - required', async () => { - const product = { + test('Can bulk publish product with compo - required', async () => { + const product1 = { name: 'Product 1', description: 'Product description', compo: { @@ -256,22 +255,38 @@ describe('CM API - Basic + compo', () => { description: 'short', }, }; - const res = await rq({ + + const product2 = { + name: 'Product 2', + description: 'Product description', + compo: { + name: 'compo name', + description: 'short', + }, + }; + + const res1 = await rq({ method: 'POST', url: '/content-manager/collection-types/api::product-with-compo-and-dp.product-with-compo-and-dp', - body: product, + body: product1, + }); + + const res2 = await rq({ + method: 'POST', + url: '/content-manager/collection-types/api::product-with-compo-and-dp.product-with-compo-and-dp', + body: product2, }); const publishRes = await rq({ method: 'POST', url: `/content-manager/collection-types/api::product-with-compo-and-dp.product-with-compo-and-dp/actions/bulkPublish`, body: { - ids: [res.body.documentId], + documentIds: [res1.body.data.documentId, res2.body.data.documentId], }, }); expect(publishRes.statusCode).toBe(200); - expect(publishRes.body).toMatchObject({ count: 1 }); + expect(publishRes.body).toMatchObject({ count: 2 }); }); }); }); diff --git a/tests/api/plugins/i18n/content-manager/crud.test.api.js b/tests/api/plugins/i18n/content-manager/crud.test.api.js index c7fe7272afa..4e70982eb3d 100644 --- a/tests/api/plugins/i18n/content-manager/crud.test.api.js +++ b/tests/api/plugins/i18n/content-manager/crud.test.api.js @@ -38,6 +38,35 @@ const data = { categories: [], }; +const createCategory = async (name, locale = 'en') => { + const res = await rq({ + method: 'POST', + url: '/content-manager/collection-types/api::category.category', + body: { + name, + locale, + }, + }); + + return res.body.data; +}; + +const addLocaleToDocument = async (documentId, locale, name) => { + const res = await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + qs: { + locale, + }, + body: { + name, + locale, + }, + }); + + return res.body.data; +}; + describe('i18n - Content API', () => { const builder = createTestBuilder(); @@ -202,38 +231,60 @@ describe('i18n - Content API', () => { }); }); - // V5: Fix bulk actions - describe.skip('Bulk Delete', () => { - test('default locale', async () => { + describe('Bulk Delete', () => { + test('if locale is not provided, should delete only the default locale', async () => { + const enCategory = await createCategory('Category'); + await addLocaleToDocument(enCategory.documentId, 'es-AR', 'Categoria 1'); + const res = await rq({ method: 'POST', url: '/content-manager/collection-types/api::category.category/actions/bulkDelete', body: { - ids: [data.categories[0].id], + documentIds: [enCategory.documentId], }, }); - const { statusCode, body } = res; - - expect(statusCode).toBe(200); - expect(body.data).toMatchObject({ count: 1 }); - data.categories.shift(); + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ count: 1 }); }); - test('non-default locale', async () => { + test('if locale is provided, should delete the document in the specified locale', async () => { + const enCategory = await createCategory('Category 2'); + await addLocaleToDocument(enCategory.documentId, 'es-AR', 'Categoria 2'); + const res = await rq({ method: 'POST', url: '/content-manager/collection-types/api::category.category/actions/bulkDelete', + qs: { + locale: 'es-AR', + }, body: { - ids: [data.categories[0].id], + documentIds: [enCategory.documentId], }, }); - const { statusCode, body } = res; + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ count: 1 }); + }); + + test('if one document doesnt exist in the specified locale, should ignore it', async () => { + const category1 = await createCategory('Category 3'); + await addLocaleToDocument(category1.documentId, 'es-AR', 'Categoria 3'); + const category2 = await createCategory('Category 4'); + + const res = await rq({ + method: 'POST', + url: '/content-manager/collection-types/api::category.category/actions/bulkDelete', + qs: { + locale: 'es-AR', + }, + body: { + documentIds: [category1.documentId, category2.documentId], + }, + }); - expect(statusCode).toBe(200); - expect(body.data).toMatchObject({ count: 1 }); - data.categories.shift(); + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ count: 1 }); }); }); });