Add bulk publish and unpublish

This commit is contained in:
Mark Kaylor 2023-04-28 09:46:04 +02:00
parent 1347bb4f26
commit 119b88a1b1
9 changed files with 293 additions and 22 deletions

View File

@ -240,10 +240,10 @@ const BulkActionsBar = ({
}
};
const handleConfirmUnpublishAll = async () => {
const handleConfirmUnpublishAll = () => {
try {
setIsConfirmButtonLoading(true);
await onConfirmUnpublishAll(selectedEntries);
onConfirmUnpublishAll(selectedEntries);
clearSelectedEntries();
setIsConfirmButtonLoading(false);
} catch (err) {

View File

@ -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'),

View File

@ -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,

View File

@ -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",

View File

@ -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 });

View File

@ -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,

View File

@ -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',

View File

@ -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 },
});
});
});
});

View File

@ -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');