From 119b88a1b1033a486f074ea2d524cb4dcbf32cac Mon Sep 17 00:00:00 2001 From: Mark Kaylor Date: Fri, 28 Apr 2023 09:46:04 +0200 Subject: [PATCH] Add bulk publish and unpublish --- .../DynamicTable/BulkActionsBar/index.js | 4 +- .../BulkActionsBar/tests/index.test.js | 2 +- .../content-manager/pages/ListView/index.js | 95 ++++++++++++++++--- .../core/admin/admin/src/translations/en.json | 2 + .../server/controllers/collection-types.js | 62 +++++++++++- .../server/controllers/validation/index.js | 4 +- .../content-manager/server/routes/admin.js | 32 +++++++ .../services/__tests__/entity-manager.test.js | 42 ++++++++ .../server/services/entity-manager.js | 72 ++++++++++++++ 9 files changed, 293 insertions(+), 22 deletions(-) diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/index.js b/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/index.js index 4d6397c044..ef6828f860 100644 --- a/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/index.js +++ b/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/index.js @@ -240,10 +240,10 @@ const BulkActionsBar = ({ } }; - const handleConfirmUnpublishAll = async () => { + const handleConfirmUnpublishAll = () => { try { setIsConfirmButtonLoading(true); - await onConfirmUnpublishAll(selectedEntries); + onConfirmUnpublishAll(selectedEntries); clearSelectedEntries(); setIsConfirmButtonLoading(false); } catch (err) { diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/tests/index.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/tests/index.test.js index 047b7e188d..bbc2c0d47a 100644 --- a/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/tests/index.test.js +++ b/packages/core/admin/admin/src/content-manager/components/DynamicTable/BulkActionsBar/tests/index.test.js @@ -3,7 +3,7 @@ import { act, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { IntlProvider } from 'react-intl'; -import BulkActionsBar from '../index'; +import BulkActionsBar from '../index' jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), diff --git a/packages/core/admin/admin/src/content-manager/pages/ListView/index.js b/packages/core/admin/admin/src/content-manager/pages/ListView/index.js index 4f40360de1..f9e3c4af03 100644 --- a/packages/core/admin/admin/src/content-manager/pages/ListView/index.js +++ b/packages/core/admin/admin/src/content-manager/pages/ListView/index.js @@ -36,6 +36,7 @@ import { } from '@strapi/design-system'; import { ArrowLeft, Plus, Cog } from '@strapi/icons'; +import { useMutation } from 'react-query'; import DynamicTable from '../../components/DynamicTable'; import AttributeFilter from '../../components/AttributeFilter'; @@ -102,6 +103,14 @@ function ListView({ const fetchClient = useFetchClient(); const { post, del } = fetchClient; + const bulkAction = async ({ query, input }) => { + const { data } = await post(query, { ...input }); + + return data; + }; + + const bulkPublishMutation = useMutation(bulkAction); + // FIXME // Using a ref to avoid requests being fired multiple times on slug on change // We need it because the hook as mulitple dependencies so it may run before the permissions have checked @@ -180,21 +189,6 @@ function ListView({ [fetchData, params, slug, toggleNotification, formatAPIError, post] ); - const handleConfirmPublishAllData = async (selectedEntries) => { - const validations = await validateEntriesToPublish(selectedEntries); - console.log('Validations', validations); - - if (validations.errors.length > 0) { - // TODO make a request to the API and refetch the data - console.info('Publishing all data', selectedEntries); - } - }; - - const handleConfirmUnpublishAllData = (ids) => { - // TODO make a request to the API and refetch the data - console.info('Unpublishing all data', ids); - }; - const handleConfirmDeleteData = useCallback( async (idToDelete) => { try { @@ -254,6 +248,76 @@ function ListView({ return validations; }; + const handleConfirmPublishAllData = async (selectedEntries) => { + const validations = await validateEntriesToPublish(selectedEntries); + + if (Object.values(validations.errors).length) { + toggleNotification({ + type: 'warning', + title: { + id: 'content-manager.listView.validation.errors.title', + defaultMessage: 'Action required', + }, + message: { + id: 'content-manager.listView.validation.errors.message', + defaultMessage: + 'Please make sure all fields are valid before publishing (required field, min/max character limit, etc.)', + }, + }); + + return; + } + + bulkPublishMutation.mutate( + { + query: `/content-manager/collection-types/${contentType.uid}/actions/bulkPublish`, + input: { ids: selectedEntries }, + }, + { + onSuccess() { + fetchData(`/content-manager/collection-types/${slug}${params}`); + toggleNotification({ + type: 'success', + message: { id: 'content-manager.success.record.publish', defaultMessage: 'Published' }, + }); + }, + onError(error) { + toggleNotification({ + type: 'warning', + message: formatAPIError(error), + }); + }, + } + ); + }; + + const handleConfirmUnpublishAllData = (selectedEntries) => { + bulkPublishMutation.mutate( + { + query: `/content-manager/collection-types/${contentType.uid}/actions/bulkUnpublish`, + input: { ids: selectedEntries }, + }, + { + onSuccess() { + fetchData(`/content-manager/collection-types/${slug}${params}`); + toggleNotification({ + type: 'success', + message: { + id: 'content-manager.success.record.unpublish', + defaultMessage: 'Unpublished', + }, + }); + }, + onError(error) { + toggleNotification({ + type: 'warning', + message: formatAPIError(error), + }); + }, + } + ); + }; + useEffect(() => { const CancelToken = axios.CancelToken; const source = CancelToken.source(); @@ -417,6 +481,7 @@ ListView.propTypes = { layout: PropTypes.exact({ components: PropTypes.object.isRequired, contentType: PropTypes.shape({ + uid: PropTypes.string.isRequired, attributes: PropTypes.object.isRequired, metadatas: PropTypes.object.isRequired, info: PropTypes.shape({ displayName: PropTypes.string.isRequired }).isRequired, diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index cef3df52c5..f5da5a6bca 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -817,6 +817,8 @@ "content-manager.success.record.save": "Saved", "content-manager.success.record.unpublish": "Unpublished", "content-manager.utils.data-loaded": "The {number, plural, =1 {entry has} other {entries have}} successfully been loaded", + "content-manager.listView.validation.errors.title": "Action required", + "content-manager.listView.validation.errors.message": "Please make sure all fields are valid before publishing (required field, min/max character limit, etc.)", "dark": "Dark", "form.button.continue": "Continue", "form.button.done": "Done", diff --git a/packages/core/content-manager/server/controllers/collection-types.js b/packages/core/content-manager/server/controllers/collection-types.js index 0bfb296d46..79b2f38a8d 100644 --- a/packages/core/content-manager/server/controllers/collection-types.js +++ b/packages/core/content-manager/server/controllers/collection-types.js @@ -3,7 +3,7 @@ const { setCreatorFields, pipeAsync } = require('@strapi/utils'); const { getService, pickWritableAttributes } = require('../utils'); -const { validateBulkDeleteInput } = require('./validation'); +const { validateBulkActionInput } = require('./validation'); module.exports = { async find(ctx) { @@ -181,6 +181,64 @@ module.exports = { ctx.body = await permissionChecker.sanitizeOutput(result); }, + async bulkPublish(ctx) { + const { userAbility } = ctx.state; + const { model } = ctx.params; + const { query, body } = ctx.request; + const { ids } = body; + + await validateBulkActionInput(body); + + const entityManager = getService('entity-manager'); + const permissionChecker = getService('permission-checker').create({ userAbility, model }); + + if (permissionChecker.cannot.publish()) { + return ctx.forbidden(); + } + + const permissionQuery = await permissionChecker.sanitizedQuery.publish(query); + + const idsWhereClause = { id: { $in: ids } }; + const params = { + ...permissionQuery, + filters: { + $and: [idsWhereClause].concat(permissionQuery.filters || []), + }, + }; + + const { count } = await entityManager.publishMany(params, model); + ctx.body = { count }; + }, + + async bulkUnpublish(ctx) { + const { userAbility } = ctx.state; + const { model } = ctx.params; + const { query, body } = ctx.request; + const { ids } = body; + + await validateBulkActionInput(body); + + const entityManager = getService('entity-manager'); + const permissionChecker = getService('permission-checker').create({ userAbility, model }); + + if (permissionChecker.cannot.unpublish()) { + return ctx.forbidden(); + } + + const permissionQuery = await permissionChecker.sanitizedQuery.unpublish(query); + + const idsWhereClause = { id: { $in: ids } }; + const params = { + ...permissionQuery, + filters: { + $and: [idsWhereClause].concat(permissionQuery.filters || []), + }, + }; + + const { count } = await entityManager.unpublishMany(params, model); + ctx.body = { count }; + }, + async unpublish(ctx) { const { userAbility, user } = ctx.state; const { id, model } = ctx.params; @@ -217,7 +275,7 @@ module.exports = { const { query, body } = ctx.request; const { ids } = body; - await validateBulkDeleteInput(body); + await validateBulkActionInput(body); const entityManager = getService('entity-manager'); const permissionChecker = getService('permission-checker').create({ userAbility, model }); diff --git a/packages/core/content-manager/server/controllers/validation/index.js b/packages/core/content-manager/server/controllers/validation/index.js index 0aaa343de6..4e4fa40057 100644 --- a/packages/core/content-manager/server/controllers/validation/index.js +++ b/packages/core/content-manager/server/controllers/validation/index.js @@ -13,7 +13,7 @@ const TYPES = ['singleType', 'collectionType']; */ const kindSchema = yup.string().oneOf(TYPES).nullable(); -const bulkDeleteInputSchema = yup +const bulkActionInputSchema = yup .object({ ids: yup.array().of(yup.strapiID()).min(1).required(), }) @@ -64,7 +64,7 @@ const validatePagination = ({ page, pageSize }) => { module.exports = { createModelConfigurationSchema, validateKind: validateYupSchema(kindSchema), - validateBulkDeleteInput: validateYupSchema(bulkDeleteInputSchema), + validateBulkActionInput: validateYupSchema(bulkActionInputSchema), validateGenerateUIDInput: validateYupSchema(generateUIDInputSchema), validateCheckUIDAvailabilityInput: validateYupSchema(checkUIDAvailabilityInputSchema), validateUIDField, diff --git a/packages/core/content-manager/server/routes/admin.js b/packages/core/content-manager/server/routes/admin.js index c33cee7c6e..0eb7832166 100644 --- a/packages/core/content-manager/server/routes/admin.js +++ b/packages/core/content-manager/server/routes/admin.js @@ -323,6 +323,38 @@ module.exports = { ], }, }, + { + method: 'POST', + path: '/collection-types/:model/actions/bulkPublish', + handler: 'collection-types.bulkPublish', + config: { + middlewares: [routing], + policies: [ + 'plugin::content-manager.has-draft-and-publish', + 'admin::isAuthenticatedAdmin', + { + name: 'plugin::content-manager.hasPermissions', + config: { actions: ['plugin::content-manager.explorer.delete'] }, + }, + ], + }, + }, + { + method: 'POST', + path: '/collection-types/:model/actions/bulkUnpublish', + handler: 'collection-types.bulkUnpublish', + config: { + middlewares: [routing], + policies: [ + 'plugin::content-manager.has-draft-and-publish', + 'admin::isAuthenticatedAdmin', + { + name: 'plugin::content-manager.hasPermissions', + config: { actions: ['plugin::content-manager.explorer.delete'] }, + }, + ], + }, + }, { method: 'GET', path: '/collection-types/:model/:id/actions/numberOfDraftRelations', diff --git a/packages/core/content-manager/server/services/__tests__/entity-manager.test.js b/packages/core/content-manager/server/services/__tests__/entity-manager.test.js index 8b4b00b1a4..15aae7bd73 100644 --- a/packages/core/content-manager/server/services/__tests__/entity-manager.test.js +++ b/packages/core/content-manager/server/services/__tests__/entity-manager.test.js @@ -5,6 +5,7 @@ const entityManagerLoader = require('../entity-manager'); let entityManager; +const queryUpdateMock = jest.fn(() => Promise.resolve()); describe('Content-Manager', () => { const fakeModel = { modelName: 'fake model', @@ -18,8 +19,15 @@ describe('Content-Manager', () => { entityService: { update: jest.fn().mockReturnValue({ id: 1, publishedAt: new Date() }), }, + db: { + query: jest.fn(() => ({ + findMany: jest.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]), + updateMany: queryUpdateMock, + })), + }, entityValidator: { validateEntityCreation() {}, + validateEntityUpdate: jest.fn().mockReturnValue([{ id: 1 }, { id: 2 }]), }, eventHub: { emit: jest.fn(), sanitizeEntity: (entity) => entity }, getModel: jest.fn(() => fakeModel), @@ -44,12 +52,32 @@ describe('Content-Manager', () => { populate: {}, }); }); + + test('Publish many content-types', async () => { + const uid = 'api::test.test'; + const params = { filters: { $and: [1, 2] } }; + + await entityManager.publishMany(params, uid); + + expect(strapi.db.query().updateMany).toBeCalledWith({ + where: { + $and: [1, 2], + }, + data: { publishedAt: expect.any(Date) }, + }); + }); }); describe('Unpublish', () => { const defaultConfig = {}; beforeEach(() => { global.strapi = { + db: { + query: jest.fn(() => ({ + findMany: jest.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]), + updateMany: queryUpdateMock, + })), + }, entityService: { update: jest.fn().mockReturnValue({ id: 1, publishedAt: null }), }, @@ -76,5 +104,19 @@ describe('Content-Manager', () => { populate: {}, }); }); + + test('Unpublish many content-types', async () => { + const uid = 'api::test.test'; + const params = { filters: { $and: [1, 2] } }; + + await entityManager.unpublishMany(params, uid); + + expect(strapi.db.query().updateMany).toBeCalledWith({ + where: { + $and: [1, 2], + }, + data: { publishedAt: null }, + }); + }); }); }); diff --git a/packages/core/content-manager/server/services/entity-manager.js b/packages/core/content-manager/server/services/entity-manager.js index 28393bddad..f059bd4ed2 100644 --- a/packages/core/content-manager/server/services/entity-manager.js +++ b/packages/core/content-manager/server/services/entity-manager.js @@ -3,6 +3,7 @@ const { assoc, has, prop, omit } = require('lodash/fp'); const strapiUtils = require('@strapi/utils'); const { mapAsync } = require('@strapi/utils'); +const { transformParamsToQuery } = require('@strapi/utils').convertQueryParams; const { ApplicationError } = require('@strapi/utils').errors; const { getDeepPopulate, getDeepPopulateDraftCount } = require('./utils/populate'); const { getDeepRelationsCount } = require('./utils/count'); @@ -266,6 +267,77 @@ module.exports = ({ strapi }) => ({ return mappedEntity; }, + async publishMany(opts, uid) { + const params = { + ...opts, + data: { + [PUBLISHED_AT_ATTRIBUTE]: new Date(), + }, + }; + + const query = transformParamsToQuery(uid, params); + const entitiesToUpdate = await strapi.db.query(uid).findMany(query); + // No entities to update, return early + if (!entitiesToUpdate.length) { + return null; + } + + // Validate entities before publishing, throw if invalid + await Promise.all( + entitiesToUpdate.map((entityToUpdate) => + strapi.entityValidator.validateEntityCreation( + strapi.getModel(uid), + entityToUpdate, + { + isDraft: true, + }, + entityToUpdate + ) + ) + ); + // Everything is valid, publish + const publishedEntitiesCount = await strapi.db + .query(uid) + .updateMany({ ...query, data: params.data }); + + // Get the updated entities since updateMany only returns the count + const publishedEntities = await strapi.db.query(uid).findMany(query); + // Emit the publish event for all updated entities + await Promise.all(publishedEntities.map((entity) => emitEvent(ENTRY_PUBLISH, entity, uid))); + + // Return the number of published entities + return publishedEntitiesCount; + }, + + async unpublishMany(opts, uid) { + const params = { + ...opts, + data: { + [PUBLISHED_AT_ATTRIBUTE]: null, + }, + }; + + const query = transformParamsToQuery(uid, params); + const entitiesToUpdate = await strapi.db.query(uid).findMany(query); + // No entities to update, return early + if (!entitiesToUpdate.length) { + return null; + } + + // No need to validate, unpublish + const unpublishedEntitiesCount = await strapi.db + .query(uid) + .updateMany({ ...query, data: params.data }); + + // Get the updated entities since updateMany only returns the count + const unpublishedEntities = await strapi.db.query(uid).findMany(query); + // Emit the unpublish event for all updated entities + await Promise.all(unpublishedEntities.map((entity) => emitEvent(ENTRY_UNPUBLISH, entity, uid))); + + // Return the number of unpublished entities + return unpublishedEntitiesCount; + }, + async unpublish(entity, body = {}, uid) { if (!entity[PUBLISHED_AT_ATTRIBUTE]) { throw new ApplicationError('already.draft');