add bulk move route

This commit is contained in:
Pierre Noël 2022-05-02 16:31:27 +02:00
parent 352d16a47f
commit 34372daf8b
8 changed files with 672 additions and 129 deletions

View File

@ -1,11 +1,32 @@
'use strict';
const { getService } = require('../utils');
const { validateDeleteManyFoldersFiles } = require('./validation/admin/folder-file');
const { ACTIONS } = require('../constants');
const {
validateDeleteManyFoldersFiles,
validateMoveManyFoldersFiles,
} = require('./validation/admin/folder-file');
const folderModel = 'plugin::upload.folder';
const fileModel = 'plugin::upload.file';
module.exports = {
async deleteMany(ctx) {
const { body } = ctx.request;
const {
state: { userAbility },
} = ctx;
const pmFolder = strapi.admin.services.permission.createPermissionsManager({
ability: ctx.state.userAbility,
model: folderModel,
});
const pmFile = strapi.admin.services.permission.createPermissionsManager({
ability: userAbility,
action: ACTIONS.read,
model: fileModel,
});
await validateDeleteManyFoldersFiles(body);
@ -17,8 +38,55 @@ module.exports = {
ctx.body = {
data: {
files: deletedFiles,
folders: deletedFolders,
files: await pmFile.sanitizeOutput(deletedFiles),
folders: await pmFolder.sanitizeOutput(deletedFolders),
},
};
},
async moveMany(ctx) {
const { body } = ctx.request;
const {
state: { userAbility, user },
} = ctx;
const pmFolder = strapi.admin.services.permission.createPermissionsManager({
ability: ctx.state.userAbility,
model: folderModel,
});
const pmFile = strapi.admin.services.permission.createPermissionsManager({
ability: userAbility,
action: ACTIONS.read,
model: fileModel,
});
await validateMoveManyFoldersFiles(body);
const { folderIds = [], fileIds = [], destinationFolderId } = body;
const uploadService = getService('upload');
const folderService = getService('folder');
const updatedFolders = [];
// updates are done in order (not in parallele) to avoid mixing queries (path)
for (let folderId of folderIds) {
const updatedFolder = await folderService.update(
folderId,
{ parent: destinationFolderId },
{ user }
);
updatedFolders.push(updatedFolder);
}
const updatedFiles = await Promise.all(
fileIds.map(fileId =>
uploadService.updateFileInfo(fileId, { folder: destinationFolderId }, { user })
)
);
ctx.body = {
data: {
files: await pmFile.sanitizeOutput(updatedFiles),
folders: await pmFolder.sanitizeOutput(updatedFolders),
},
};
},

View File

@ -72,7 +72,7 @@ module.exports = {
model: folderModel,
});
await validateUpdateFolder(body);
await validateUpdateFolder(id)(body);
const { update } = getService('folder');

View File

@ -1,22 +1,91 @@
'use strict';
const { intersection, map, isEmpty } = require('lodash/fp');
const { yup, validateYupSchema } = require('@strapi/utils');
const { folderExists } = require('./utils');
const folderModel = 'plugin::upload.folder';
const validateDeleteManyFoldersFilesSchema = yup
.object()
.shape({
fileIds: yup
.array()
.min(1)
.of(yup.strapiID().required()),
folderIds: yup
.array()
.min(1)
.of(yup.strapiID().required()),
fileIds: yup.array().of(yup.strapiID().required()),
folderIds: yup.array().of(yup.strapiID().required()),
})
.noUnknown()
.required();
const validateStructureMoveManyFoldersFilesSchema = yup
.object()
.shape({
destinationFolderId: yup
.strapiID()
.nullable()
.test('folder-exists', 'destination folder does not exist', folderExists),
fileIds: yup.array().of(yup.strapiID().required()),
folderIds: yup.array().of(yup.strapiID().required()),
})
.noUnknown()
.required();
const validateDuplicatesMoveManyFoldersFilesSchema = yup
.object()
.test('are-folders-unique', 'some folders already exist', async function(value) {
const { folderIds, destinationFolderId } = value;
if (isEmpty(folderIds)) return true;
const folders = await strapi.entityService.findMany(folderModel, {
fields: ['name'],
filters: { id: { $in: folderIds } },
});
// TODO: handle when parent is null
const existingFolders = await strapi.entityService.findMany(folderModel, {
fields: ['name'],
filters: { parent: { id: destinationFolderId } },
});
const duplicatedNames = intersection(map('name', folders), map('name', existingFolders));
if (duplicatedNames.length > 0) {
return this.createError({
message: `some folders already exists: ${duplicatedNames.join(', ')}`,
});
}
return true;
});
const validateMoveFoldersNotInsideThemselvesSchema = yup
.object()
.test('dont-move-inside-self', 'folders cannot be moved inside themselves', async function(
value
) {
const { folderIds, destinationFolderId } = value;
if (destinationFolderId === null || isEmpty(folderIds)) return true;
const destinationFolder = await strapi.entityService.findOne(folderModel, destinationFolderId, {
fields: ['path'],
});
const folders = await strapi.entityService.findMany(folderModel, {
fields: ['name', 'path'],
filters: { id: { $in: folderIds } },
});
const unmovableFoldersNames = folders
.filter(folder => destinationFolder.path.startsWith(folder.path))
.map(f => f.name);
if (unmovableFoldersNames.length > 0) {
return this.createError({
message: `folders cannot be moved inside themselves: ${unmovableFoldersNames.join(', ')}`,
});
}
return true;
});
module.exports = {
validateDeleteManyFoldersFiles: validateYupSchema(validateDeleteManyFoldersFilesSchema),
async validateMoveManyFoldersFiles(body) {
await validateYupSchema(validateStructureMoveManyFoldersFilesSchema)(body);
await validateYupSchema(validateDuplicatesMoveManyFoldersFilesSchema)(body);
await validateYupSchema(validateMoveFoldersNotInsideThemselvesSchema)(body);
},
};

View File

@ -1,25 +1,27 @@
'use strict';
const { isUndefined, get } = require('lodash/fp');
const { yup, validateYupSchema } = require('@strapi/utils');
const { isNil } = require('lodash/fp');
const { getService } = require('../../../utils');
const { folderExists } = require('./utils');
const folderModel = 'plugin::upload.folder';
const NO_SLASH_REGEX = /^[^/]+$/;
const NO_SPACES_AROUND = /^(?! ).+(?<! )$/;
const folderExists = async folderId => {
if (isNil(folderId)) {
return true;
const isNameUniqueInFolder = (id) => async function(name) {
const { exists } = getService('folder');
const filters = { name, parent: this.parent.parent || null };
if (id) {
filters.id = { $ne: id };
if (isUndefined(name)) {
const existingFolder = await strapi.entityService.findOne(folderModel, id);
filters.name = get('name', existingFolder);
}
}
const exists = await getService('folder').exists({ id: folderId });
return exists;
};
const isNameUniqueInFolder = async function(name) {
const { exists } = getService('folder');
const doesExist = await exists({ parent: this.parent.parent || null, name });
const doesExist = await exists(filters);
return !doesExist;
}
@ -32,7 +34,7 @@ const validateCreateFolderSchema = yup
.matches(NO_SLASH_REGEX, 'name cannot contain slashes')
.matches(NO_SPACES_AROUND, 'name cannot start or end with a whitespace')
.required()
.test('is-folder-unique', 'name already taken', isNameUniqueInFolder),
.test('is-folder-unique', 'folder already exists', isNameUniqueInFolder()),
parent: yup
.strapiID()
.nullable()
@ -41,7 +43,8 @@ const validateCreateFolderSchema = yup
.noUnknown()
.required();
const validateUpdateFolderSchema = yup
const validateUpdateFolderSchema = id =>
yup
.object()
.shape({
name: yup
@ -49,7 +52,7 @@ const validateUpdateFolderSchema = yup
.min(1)
.matches(NO_SLASH_REGEX, 'name cannot contain slashes')
.matches(NO_SPACES_AROUND, 'name cannot start or end with a whitespace')
.test('is-folder-unique', 'name already taken', isNameUniqueInFolder),
.test('is-folder-unique', 'folder already exists', isNameUniqueInFolder(id)),
parent: yup
.strapiID()
.nullable()
@ -60,5 +63,5 @@ const validateUpdateFolderSchema = yup
module.exports = {
validateCreateFolder: validateYupSchema(validateCreateFolderSchema),
validateUpdateFolder: validateYupSchema(validateUpdateFolderSchema),
validateUpdateFolder: id => validateYupSchema(validateUpdateFolderSchema(id)),
};

View File

@ -0,0 +1,18 @@
'use strict';
const { isNil } = require('lodash/fp');
const { getService } = require('../../../utils');
const folderExists = async folderId => {
if (isNil(folderId)) {
return true;
}
const exists = await getService('folder').exists({ id: folderId });
return exists;
};
module.exports = {
folderExists,
};

View File

@ -171,5 +171,21 @@ module.exports = {
],
},
},
{
method: 'POST',
path: '/actions/bulk-move',
handler: 'admin-folder-file.moveMany',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::upload.read'],
},
},
],
},
},
],
};

View File

@ -0,0 +1,433 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { createTestBuilder } = require('../../../../../test/helpers/builder');
const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
const { createAuthRequest } = require('../../../../../test/helpers/request');
let strapi;
let rq;
let data = {
folders: [],
files: [],
};
const createFolder = async (name, parent = null) => {
const res = await rq({
method: 'POST',
url: '/upload/folders',
body: { name, parent },
});
return res.body.data;
};
const createAFile = async (parent = null) => {
const res = await rq({
method: 'POST',
url: '/upload',
formData: {
files: fs.createReadStream(path.join(__dirname, '../utils/rec.jpg')),
fileInfo: JSON.stringify({ folder: parent }),
},
});
return res.body[0];
};
describe('Bulk actions fot folders & files', () => {
const builder = createTestBuilder();
beforeAll(async () => {
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
});
afterAll(async () => {
await rq({
method: 'POST',
url: '/upload/actions/bulk-delete',
body: {
folderIds: data.folders.map(f => f.id),
},
});
await strapi.destroy();
await builder.cleanup();
});
describe('delete', () => {
test('Can delete folders and files', async () => {
const folder1 = await createFolder('folder-a-1', null);
const folder1a = await createFolder('folder-a-1a', folder1.id);
const folder1b = await createFolder('folder-a-1b', folder1.id);
const folder1a1 = await createFolder('folder-a-1a1', folder1a.id);
const file1 = await createAFile(null);
const file1b = await createAFile(folder1b.id);
const file1a = await createAFile(folder1a.id);
const file1a1 = await createAFile(folder1a1.id);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-delete',
body: {
fileIds: [file1.id],
folderIds: [folder1a.id],
},
});
expect(res.body.data).toMatchObject({
files: [
{
alternativeText: null,
caption: null,
createdAt: expect.anything(),
ext: '.jpg',
folderPath: '/',
formats: null,
hash: expect.anything(),
height: 20,
id: file1.id,
mime: 'image/jpeg',
name: 'rec.jpg',
previewUrl: null,
provider: 'local',
provider_metadata: null,
size: 0.27,
updatedAt: expect.anything(),
url: expect.anything(),
width: 20,
},
],
folders: [
{
id: folder1a.id,
name: 'folder-a-1a',
path: expect.anything(),
uid: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
},
],
});
const resFolder = await rq({
method: 'GET',
url: '/upload/folders?pagination[pageSize]=100',
});
const existingfoldersIds = resFolder.body.results.map(f => f.id);
expect(existingfoldersIds).toEqual(expect.not.arrayContaining([folder1a.id, folder1a1.id]));
expect(existingfoldersIds).toEqual(expect.arrayContaining([folder1.id, folder1b.id]));
const resFiles = await rq({
method: 'GET',
url: '/upload/files',
qs: {
pageSize: 100,
},
});
const existingfilesIds = resFiles.body.results.map(f => f.id);
expect(existingfilesIds).toEqual(
expect.not.arrayContaining([file1.id, file1a.id, file1a1.id])
);
expect(existingfilesIds).toEqual(expect.arrayContaining([file1b.id]));
data.folders.push(folder1, folder1b);
data.files.push(file1b);
});
});
describe('move', () => {
test('Can move folders and files into another folder', async () => {
const folder1 = await createFolder('folder-b-1', null);
const folder1a = await createFolder('folder-b-1a', folder1.id);
const folder1b = await createFolder('folder-b-1b', folder1.id);
const folder1a1 = await createFolder('folder-b-1a1', folder1a.id);
const file1 = await createAFile(null);
const file1a = await createAFile(folder1a.id);
const file1b = await createAFile(folder1b.id);
const file1a1 = await createAFile(folder1a1.id);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-move',
body: {
destinationFolderId: folder1b.id,
fileIds: [file1a.id],
folderIds: [folder1a.id],
},
});
expect(res.body.data).toMatchObject({
files: [
{
alternativeText: null,
caption: null,
createdAt: expect.anything(),
ext: '.jpg',
folderPath: folder1b.path,
formats: null,
hash: expect.anything(),
height: 20,
id: file1a.id,
mime: 'image/jpeg',
name: 'rec.jpg',
previewUrl: null,
provider: 'local',
provider_metadata: null,
size: 0.27,
updatedAt: expect.anything(),
url: expect.anything(),
width: 20,
},
],
folders: [
{
id: folder1a.id,
name: 'folder-b-1a',
path: `${folder1b.path}/${folder1a.uid}`,
uid: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
},
],
});
const {
body: { results: folderResults },
} = await rq({
method: 'GET',
url: '/upload/folders?pagination[pageSize]=100&populate=parent',
qs: {
pagination: { pageSize: 100 },
populate: 'parent',
sort: 'id:asc',
filters: { id: { $in: [folder1.id, folder1a.id, folder1b.id, folder1a1.id] } },
},
});
expect(folderResults[0]).toMatchObject({ ...folder1, parent: null });
expect(folderResults[1]).toMatchObject({
...folder1a,
path: `${folder1b.path}/${folder1a.uid}`,
parent: { id: folder1b.id },
updatedAt: expect.anything(),
});
expect(folderResults[2]).toMatchObject({ ...folder1b, parent: { id: folder1.id } });
expect(folderResults[3]).toMatchObject({
...folder1a1,
path: `${folder1b.path}/${folder1a.uid}/${folder1a1.uid}`,
parent: { id: folder1a.id },
});
const {
body: { results: fileResults },
} = await rq({
method: 'GET',
url: '/upload/files',
qs: {
pageSize: 100,
populate: 'folder',
sort: 'id:asc',
filters: { id: { $in: [file1.id, file1a.id, file1b.id, file1a1.id] } },
},
});
expect(fileResults[0]).toMatchObject({ ...file1, folder: null });
expect(fileResults[1]).toMatchObject({
...file1a,
folderPath: folder1b.path,
folder: { id: folder1b.id },
updatedAt: expect.anything(),
});
expect(fileResults[2]).toMatchObject({ ...file1b, folder: { id: folder1b.id } });
expect(fileResults[3]).toMatchObject({
...file1a1,
folderPath: `${folder1b.path}/${folder1a.uid}/${folder1a1.uid}`,
folder: { id: folder1a1.id },
});
data.folders.push(...folderResults);
data.files.push(...fileResults);
});
test('Can move folders and files to the root level', async () => {
const folder1 = await createFolder('folder-c-1', null);
const folder1a = await createFolder('folder-c-1a', folder1.id);
const folder1a1 = await createFolder('folder-c-1a1', folder1a.id);
const file1 = await createAFile(null);
const file1a = await createAFile(folder1a.id);
const file1a1 = await createAFile(folder1a1.id);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-move',
body: {
destinationFolderId: null,
fileIds: [file1a.id],
folderIds: [folder1a.id],
},
});
expect(res.body.data).toMatchObject({
files: [
{
alternativeText: null,
caption: null,
createdAt: expect.anything(),
ext: '.jpg',
folderPath: '/',
formats: null,
hash: expect.anything(),
height: 20,
id: file1a.id,
mime: 'image/jpeg',
name: 'rec.jpg',
previewUrl: null,
provider: 'local',
provider_metadata: null,
size: 0.27,
updatedAt: expect.anything(),
url: expect.anything(),
width: 20,
},
],
folders: [
{
id: folder1a.id,
name: 'folder-c-1a',
path: `/${folder1a.uid}`,
uid: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
},
],
});
const {
body: { results: folderResults },
} = await rq({
method: 'GET',
url: '/upload/folders?pagination[pageSize]=100&populate=parent',
qs: {
pagination: { pageSize: 100 },
populate: 'parent',
sort: 'id:asc',
filters: { id: { $in: [folder1.id, folder1a.id, folder1a1.id] } },
},
});
expect(folderResults[0]).toMatchObject({ ...folder1, parent: null });
expect(folderResults[1]).toMatchObject({
...folder1a,
path: `/${folder1a.uid}`,
parent: null,
updatedAt: expect.anything(),
});
expect(folderResults[2]).toMatchObject({
...folder1a1,
path: `/${folder1a.uid}/${folder1a1.uid}`,
parent: { id: folder1a.id },
});
const {
body: { results: fileResults },
} = await rq({
method: 'GET',
url: '/upload/files',
qs: {
pageSize: 100,
populate: 'folder',
sort: 'id:asc',
filters: { id: { $in: [file1.id, file1a.id, file1a1.id] } },
},
});
expect(fileResults[0]).toMatchObject({ ...file1, folder: null });
expect(fileResults[1]).toMatchObject({
...file1a,
folderPath: '/',
folder: null,
updatedAt: expect.anything(),
});
expect(fileResults[2]).toMatchObject({
...file1a1,
folderPath: `/${folder1a.uid}/${folder1a1.uid}`,
folder: { id: folder1a1.id },
});
data.folders.push(...folderResults);
data.files.push(...fileResults);
});
test('Cannot move a folder inside itself (0 level)', async () => {
const folder1 = await createFolder('folder-d-1', null);
data.folders.push(folder1);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-move',
body: {
destinationFolderId: folder1.id,
folderIds: [folder1.id],
},
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('folders cannot be moved inside themselves: folder-d-1');
});
test('Cannot move a folder inside itself (1 level)', async () => {
const folder1 = await createFolder('folder-e-1', null);
const folder1a = await createFolder('folder-e-1a', folder1.id);
data.folders.push(folder1, folder1a);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-move',
body: {
destinationFolderId: folder1a.id,
folderIds: [folder1.id],
},
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('folders cannot be moved inside themselves: folder-e-1');
});
test('Cannot move a folder inside itself (2 levels)', async () => {
const folder1 = await createFolder('folder-f-1', null);
const folder1a = await createFolder('folder-f-1a', folder1.id);
const folder1a1 = await createFolder('folder-f-1a1', folder1a.id);
data.folders.push(folder1, folder1a, folder1a1);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-move',
body: {
destinationFolderId: folder1a1.id,
folderIds: [folder1.id],
},
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('folders cannot be moved inside themselves: folder-f-1');
});
test('Cannot move a folder if it creates a duplicate', async () => {
const folder1 = await createFolder('folder-g-1', null);
const folder1a = await createFolder('folder-g-1a', folder1.id);
const folder2 = await createFolder('folder-g-1a', null);
data.folders.push(folder1, folder1a, folder2);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-move',
body: {
destinationFolderId: folder1.id,
folderIds: [folder2.id],
},
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('some folders already exists: folder-g-1a');
});
});
});

View File

@ -126,7 +126,7 @@ describe('Folder', () => {
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('name already taken');
expect(res.body.error.message).toBe('folder already exists');
});
test('Cannot create a folder with duplicated name inside a folder', async () => {
@ -140,7 +140,7 @@ describe('Folder', () => {
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('name already taken');
expect(res.body.error.message).toBe('folder already exists');
});
test('Cannot create a folder inside a folder that does not exist', async () => {
@ -245,88 +245,6 @@ describe('Folder', () => {
});
});
describe('delete', () => {
test('Can delete folders and belonging files', async () => {
const folder1 = await createFolder('folder1', null);
const folder1a = await createFolder('folder1a', folder1.id);
const folder1b = await createFolder('folder1b', folder1.id);
const folder1a1 = await createFolder('folder1a1', folder1a.id);
const file1 = await createAFile(null);
const file1b = await createAFile(folder1b.id);
const file1a = await createAFile(folder1a.id);
const file1a1 = await createAFile(folder1a1.id);
const res = await rq({
method: 'POST',
url: '/upload/actions/bulk-delete',
body: {
fileIds: [file1.id],
folderIds: [folder1a.id],
},
});
expect(res.body.data).toMatchObject({
files: [
{
alternativeText: null,
caption: null,
createdAt: expect.anything(),
ext: '.jpg',
folderPath: '/',
formats: null,
hash: expect.anything(),
height: 20,
id: file1.id,
mime: 'image/jpeg',
name: 'rec.jpg',
previewUrl: null,
provider: 'local',
provider_metadata: null,
size: 0.27,
updatedAt: expect.anything(),
url: expect.anything(),
width: 20,
},
],
folders: [
{
id: folder1a.id,
name: 'folder1a',
path: expect.anything(),
uid: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
},
],
});
const resFolder = await rq({
method: 'GET',
url: '/upload/folders?pagination[pageSize]=100',
});
const existingfoldersIds = resFolder.body.results.map(f => f.id);
expect(existingfoldersIds).toEqual(expect.not.arrayContaining([folder1a.id, folder1a1.id]));
expect(existingfoldersIds).toEqual(expect.arrayContaining([folder1.id, folder1b.id]));
const resFiles = await rq({
method: 'GET',
url: '/upload/files',
qs: {
pageSize: 100,
},
});
const existingfilesIds = resFiles.body.results.map(f => f.id);
expect(existingfilesIds).toEqual(
expect.not.arrayContaining([file1.id, file1a.id, file1a1.id])
);
expect(existingfilesIds).toEqual(expect.arrayContaining([file1b.id]));
data.folders.push(folder1, folder1b);
});
});
describe('update', () => {
test('rename a folder', async () => {
const folder = await createFolder('folder-name', null);
@ -346,7 +264,7 @@ describe('Folder', () => {
data.folders.push(res.body.data);
});
test('cannot rename a folder if duplicated', async () => {
test('cannot move and rename a folder if duplicated', async () => {
const folder0 = await createFolder('folder-a-0', null);
const folder1 = await createFolder('folder-a-1', null);
const folder00 = await createFolder('folder-a-00', folder0.id);
@ -362,11 +280,29 @@ describe('Folder', () => {
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('name already taken');
expect(res.body.error.message).toBe('folder already exists');
});
test('cannot move a folder if duplicated', async () => {
const folder0 = await createFolder('folder-b-0', null);
const folder1 = await createFolder('folder-b-samename', null);
await createFolder('folder-b-samename', folder0.id);
data.folders.push(folder0, folder1);
const res = await rq({
method: 'PUT',
url: `/upload/folders/${folder1.id}`,
body: {
parent: folder0.id,
},
});
expect(res.status).toBe(400);
expect(res.body.error.message).toBe('folder already exists');
});
test('cannot move a folder to a folder that does not exist', async () => {
const folder = await createFolder('folder-b-0', null);
const folder = await createFolder('folder-c-0', null);
data.folders.push(folder);
const res = await rq({