mirror of
https://github.com/strapi/strapi.git
synced 2025-10-29 00:49:49 +00:00
Merge branch 'main' into feature/review-workflow-multiple-merge-main
This commit is contained in:
commit
4410fb8f97
File diff suppressed because one or more lines are too long
@ -127,6 +127,30 @@ describe('CM API - Basic + compo', () => {
|
||||
data.productsWithCompo.shift();
|
||||
});
|
||||
|
||||
test('Clone product with compo', async () => {
|
||||
const product = {
|
||||
name: 'Product 1',
|
||||
description: 'Product description',
|
||||
compo: {
|
||||
name: 'compo name',
|
||||
description: 'short',
|
||||
},
|
||||
};
|
||||
const { body: createdProduct } = await rq({
|
||||
method: 'POST',
|
||||
url: '/content-manager/collection-types/api::product-with-compo.product-with-compo',
|
||||
body: product,
|
||||
});
|
||||
|
||||
const res = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/collection-types/api::product-with-compo.product-with-compo/clone/${createdProduct.id}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toMatchObject(product);
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
test('Cannot create product with compo - compo required', async () => {
|
||||
const product = {
|
||||
|
||||
@ -136,6 +136,38 @@ describe('CM API - Basic + dz + draftAndPublish', () => {
|
||||
data.productsWithDzAndDP.shift();
|
||||
});
|
||||
|
||||
test('Clone product with compo', async () => {
|
||||
const product = {
|
||||
name: 'Product 1',
|
||||
description: 'Product description',
|
||||
dz: [
|
||||
{
|
||||
__component: 'default.compo',
|
||||
name: 'compo name',
|
||||
description: 'short',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { body: createdProduct } = await rq({
|
||||
method: 'POST',
|
||||
url: '/content-manager/collection-types/api::product-with-dz-and-dp.product-with-dz-and-dp',
|
||||
body: product,
|
||||
});
|
||||
|
||||
const res = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/collection-types/api::product-with-dz-and-dp.product-with-dz-and-dp/clone/${createdProduct.id}`,
|
||||
body: {
|
||||
publishedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toMatchObject(product);
|
||||
// When cloning the new entry must be a draft
|
||||
expect(res.body.publishedAt).toBeNull();
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
describe.each(['create', 'update'])('%p', (method) => {
|
||||
test(`Can ${method} product with compo - compo required - []`, async () => {
|
||||
|
||||
@ -97,13 +97,13 @@ describe('CM API - Basic + dz', () => {
|
||||
|
||||
test('Update product with compo', async () => {
|
||||
const product = {
|
||||
name: 'Product 1 updated',
|
||||
description: 'Updated Product description',
|
||||
name: 'Product 1',
|
||||
description: 'Product description',
|
||||
dz: [
|
||||
{
|
||||
__component: 'default.compo',
|
||||
name: 'compo name updated',
|
||||
description: 'update',
|
||||
name: 'compo name',
|
||||
description: 'short',
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -133,6 +133,34 @@ describe('CM API - Basic + dz', () => {
|
||||
data.productsWithDz.shift();
|
||||
});
|
||||
|
||||
test('Clone product with compo', async () => {
|
||||
const product = {
|
||||
name: 'Product 1',
|
||||
description: 'Product description',
|
||||
dz: [
|
||||
{
|
||||
__component: 'default.compo',
|
||||
name: 'compo name',
|
||||
description: 'short',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { body: createdProduct } = await rq({
|
||||
method: 'POST',
|
||||
url: '/content-manager/collection-types/api::product-with-dz.product-with-dz',
|
||||
body: product,
|
||||
});
|
||||
|
||||
const res = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/collection-types/api::product-with-dz.product-with-dz/clone/${createdProduct.id}`,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toMatchObject(product);
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
test('Cannot create product with compo - compo required', async () => {
|
||||
const product = {
|
||||
|
||||
@ -126,6 +126,55 @@ describe('CM API - Basic', () => {
|
||||
data.products.shift();
|
||||
});
|
||||
|
||||
test('Clone product', async () => {
|
||||
const product = {
|
||||
name: 'Product 1',
|
||||
description: 'Product description',
|
||||
hiddenAttribute: 'Secret value',
|
||||
};
|
||||
const { body: createdProduct } = await rq({
|
||||
method: 'POST',
|
||||
url: '/content-manager/collection-types/api::product.product',
|
||||
body: product,
|
||||
});
|
||||
|
||||
const res = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/collection-types/api::product.product/clone/${createdProduct.id}`,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toMatchObject(omit('hiddenAttribute', product));
|
||||
});
|
||||
|
||||
test('Clone and update product', async () => {
|
||||
const product = {
|
||||
name: 'Product 1',
|
||||
description: 'Product description',
|
||||
hiddenAttribute: 'Secret value',
|
||||
};
|
||||
const { body: createdProduct } = await rq({
|
||||
method: 'POST',
|
||||
url: '/content-manager/collection-types/api::product.product',
|
||||
body: product,
|
||||
});
|
||||
|
||||
const res = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/collection-types/api::product.product/clone/${createdProduct.id}`,
|
||||
body: {
|
||||
name: 'Product 1 updated',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
name: 'Product 1 updated',
|
||||
description: 'Product description',
|
||||
});
|
||||
});
|
||||
|
||||
describe('validators', () => {
|
||||
test('Cannot create a product - minLength', async () => {
|
||||
const product = {
|
||||
|
||||
@ -132,6 +132,16 @@ const updateEntry = async (singularName, id, data, populate) => {
|
||||
return body;
|
||||
};
|
||||
|
||||
const cloneEntry = async (singularName, id, data, populate) => {
|
||||
const { body } = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/collection-types/api::${singularName}.${singularName}/clone/${id}`,
|
||||
body: data,
|
||||
qs: { populate },
|
||||
});
|
||||
return body;
|
||||
};
|
||||
|
||||
const getRelations = async (uid, field, id) => {
|
||||
const res = await rq({
|
||||
method: 'GET',
|
||||
@ -646,7 +656,7 @@ describe('Relations', () => {
|
||||
);
|
||||
|
||||
const relationToChange = [{ id: id1, position: { before: id3 } }];
|
||||
const updatedShop = await updateEntry('shop', createdShop.id, {
|
||||
const { id } = await updateEntry('shop', createdShop.id, {
|
||||
name: 'Cazotte Shop',
|
||||
products_om: { connect: relationToChange },
|
||||
products_mm: { connect: relationToChange },
|
||||
@ -657,20 +667,16 @@ describe('Relations', () => {
|
||||
},
|
||||
});
|
||||
|
||||
let res;
|
||||
const expectedRelations = [{ id: id3 }, { id: id1 }, { id: id2 }];
|
||||
const expectedRelations = [{ id: id2 }, { id: id1 }, { id: id3 }];
|
||||
|
||||
res = await getRelations('default.compo', 'compo_products_mw', updatedShop.myCompo.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
const updatedShop = await strapi.entityService.findOne('api::shop.shop', id, {
|
||||
populate: populateShop,
|
||||
});
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mm', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mw', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_om', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.myCompo.compo_products_mw).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_mm).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_mw).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_om).toMatchObject(expectedRelations);
|
||||
});
|
||||
|
||||
test('Reorder multiple relations', async () => {
|
||||
@ -693,7 +699,7 @@ describe('Relations', () => {
|
||||
{ id: id3, position: { start: true } },
|
||||
{ id: id2, position: { after: id1 } },
|
||||
];
|
||||
const updatedShop = await updateEntry('shop', createdShop.id, {
|
||||
const { id } = await updateEntry('shop', createdShop.id, {
|
||||
name: 'Cazotte Shop',
|
||||
products_om: { connect: relationToChange },
|
||||
products_mm: { connect: relationToChange },
|
||||
@ -704,20 +710,16 @@ describe('Relations', () => {
|
||||
},
|
||||
});
|
||||
|
||||
let res;
|
||||
const expectedRelations = [{ id: id2 }, { id: id1 }, { id: id3 }];
|
||||
const updatedShop = await strapi.entityService.findOne('api::shop.shop', id, {
|
||||
populate: populateShop,
|
||||
});
|
||||
|
||||
res = await getRelations('default.compo', 'compo_products_mw', updatedShop.myCompo.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
const expectedRelations = [{ id: id3 }, { id: id1 }, { id: id2 }];
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mm', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mw', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_om', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.myCompo.compo_products_mw).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_mm).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_mw).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_om).toMatchObject(expectedRelations);
|
||||
});
|
||||
|
||||
test('Invalid reorder with non-strict mode should not give an error', async () => {
|
||||
@ -738,7 +740,7 @@ describe('Relations', () => {
|
||||
const relationToChange = [
|
||||
{ id: id1, position: { before: id3 } }, // id3 does not exist, should place it at the end
|
||||
];
|
||||
const updatedShop = await updateEntry('shop', createdShop.id, {
|
||||
const { id } = await updateEntry('shop', createdShop.id, {
|
||||
name: 'Cazotte Shop',
|
||||
products_om: { options: { strict: false }, connect: relationToChange },
|
||||
products_mm: { options: { strict: false }, connect: relationToChange },
|
||||
@ -749,20 +751,15 @@ describe('Relations', () => {
|
||||
},
|
||||
});
|
||||
|
||||
let res;
|
||||
const expectedRelations = [{ id: id1 }, { id: id2 }];
|
||||
const expectedRelations = [{ id: id2 }, { id: id1 }];
|
||||
const updatedShop = await strapi.entityService.findOne('api::shop.shop', id, {
|
||||
populate: populateShop,
|
||||
});
|
||||
|
||||
res = await getRelations('default.compo', 'compo_products_mw', updatedShop.myCompo.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mm', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mw', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_om', updatedShop.id);
|
||||
expect(res.results).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.myCompo.compo_products_mw).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_mm).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_mw).toMatchObject(expectedRelations);
|
||||
expect(updatedShop.products_om).toMatchObject(expectedRelations);
|
||||
});
|
||||
});
|
||||
|
||||
@ -794,7 +791,7 @@ describe('Relations', () => {
|
||||
const relationsToDisconnectMany =
|
||||
mode === 'object' ? [{ id: id3 }, { id: id2 }, { id: id1 }] : [id3, id2, id1];
|
||||
|
||||
const updatedShop = await updateEntry(
|
||||
const { id } = await updateEntry(
|
||||
'shop',
|
||||
createdShop.id,
|
||||
{
|
||||
@ -814,30 +811,18 @@ describe('Relations', () => {
|
||||
populateShop
|
||||
);
|
||||
|
||||
let res;
|
||||
res = await getRelations('default.compo', 'compo_products_mw', updatedShop.myCompo.id);
|
||||
expect(res.results).toMatchObject([]);
|
||||
const updatedShop = await strapi.entityService.findOne('api::shop.shop', id, {
|
||||
populate: populateShop,
|
||||
});
|
||||
|
||||
res = await getRelations('default.compo', 'compo_products_ow', updatedShop.myCompo.id);
|
||||
expect(res.data).toBe(null);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mm', updatedShop.id);
|
||||
expect(res.results).toMatchObject([]);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mo', updatedShop.id);
|
||||
expect(res.data).toBe(null);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mw', updatedShop.id);
|
||||
expect(res.results).toMatchObject([]);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_om', updatedShop.id);
|
||||
expect(res.results).toMatchObject([]);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_oo', updatedShop.id);
|
||||
expect(res.data).toBe(null);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_ow', updatedShop.id);
|
||||
expect(res.data).toBe(null);
|
||||
expect(updatedShop.myCompo.compo_products_mw).toMatchObject([]);
|
||||
expect(updatedShop.myCompo.compo_products_ow).toBe(null);
|
||||
expect(updatedShop.products_mm).toMatchObject([]);
|
||||
expect(updatedShop.products_mo).toBe(null);
|
||||
expect(updatedShop.products_mw).toMatchObject([]);
|
||||
expect(updatedShop.products_om).toMatchObject([]);
|
||||
expect(updatedShop.products_oo).toBe(null);
|
||||
expect(updatedShop.products_ow).toBe(null);
|
||||
});
|
||||
|
||||
test("Remove relations that doesn't exist doesn't fail", async () => {
|
||||
@ -862,7 +847,7 @@ describe('Relations', () => {
|
||||
const relationsToDisconnectMany =
|
||||
mode === 'object' ? [{ id: id3 }, { id: id2 }, { id: 9999 }] : [id3, id2, 9999];
|
||||
|
||||
const updatedShop = await updateEntry(
|
||||
const { id } = await updateEntry(
|
||||
'shop',
|
||||
createdShop.id,
|
||||
{
|
||||
@ -882,31 +867,220 @@ describe('Relations', () => {
|
||||
populateShop
|
||||
);
|
||||
|
||||
let res;
|
||||
res = await getRelations('default.compo', 'compo_products_mw', updatedShop.myCompo.id);
|
||||
expect(res.results).toMatchObject([{ id: id1 }]);
|
||||
const updatedShop = await strapi.entityService.findOne('api::shop.shop', id, {
|
||||
populate: populateShop,
|
||||
});
|
||||
|
||||
res = await getRelations('default.compo', 'compo_products_ow', updatedShop.myCompo.id);
|
||||
expect(res.data).toMatchObject({ id: id1 });
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mm', updatedShop.id);
|
||||
expect(res.results).toMatchObject([{ id: id1 }]);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mo', updatedShop.id);
|
||||
expect(res.data).toMatchObject({ id: id1 });
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_mw', updatedShop.id);
|
||||
expect(res.results).toMatchObject([{ id: id1 }]);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_om', updatedShop.id);
|
||||
expect(res.results).toMatchObject([{ id: id1 }]);
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_oo', updatedShop.id);
|
||||
expect(res.data).toMatchObject({ id: id1 });
|
||||
|
||||
res = await getRelations('api::shop.shop', 'products_ow', updatedShop.id);
|
||||
expect(res.data).toMatchObject({ id: id1 });
|
||||
expect(updatedShop.myCompo.compo_products_mw).toMatchObject([{ id: id1 }]);
|
||||
expect(updatedShop.myCompo.compo_products_ow).toMatchObject({ id: id1 });
|
||||
expect(updatedShop.products_mm).toMatchObject([{ id: id1 }]);
|
||||
expect(updatedShop.products_mo).toMatchObject({ id: id1 });
|
||||
expect(updatedShop.products_mw).toMatchObject([{ id: id1 }]);
|
||||
expect(updatedShop.products_om).toMatchObject([{ id: id1 }]);
|
||||
expect(updatedShop.products_oo).toMatchObject({ id: id1 });
|
||||
expect(updatedShop.products_ow).toMatchObject({ id: id1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clone entity with relations', () => {
|
||||
test('Auto cloning entity with relations should fail', async () => {
|
||||
const createdShop = await createEntry(
|
||||
'shop',
|
||||
{
|
||||
name: 'Cazotte Shop',
|
||||
products_ow: { connect: [id1] },
|
||||
products_oo: { connect: [id1] },
|
||||
products_mo: { connect: [id1] },
|
||||
products_om: { connect: [id1] },
|
||||
products_mm: { connect: [id1] },
|
||||
products_mw: { connect: [id1] },
|
||||
myCompo: {
|
||||
compo_products_ow: { connect: [id1] },
|
||||
compo_products_mw: { connect: [id1] },
|
||||
},
|
||||
},
|
||||
['myCompo']
|
||||
);
|
||||
|
||||
// Clone with empty data
|
||||
const res = await rq({
|
||||
method: 'POST',
|
||||
url: `/content-manager/collection-types/api::shop.shop/auto-clone/${createdShop.id}`,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('Clone entity with relations and connect data', async () => {
|
||||
const createdShop = await createEntry(
|
||||
'shop',
|
||||
{
|
||||
name: 'Cazotte Shop',
|
||||
products_ow: { connect: [id1] },
|
||||
products_oo: { connect: [id1] },
|
||||
products_mo: { connect: [id1] },
|
||||
products_om: { connect: [id1] },
|
||||
products_mm: { connect: [id1] },
|
||||
products_mw: { connect: [id1] },
|
||||
myCompo: {
|
||||
compo_products_ow: { connect: [id1] },
|
||||
compo_products_mw: { connect: [id1] },
|
||||
},
|
||||
},
|
||||
['myCompo']
|
||||
);
|
||||
|
||||
const { id, name } = await cloneEntry('shop', createdShop.id, {
|
||||
name: 'Cazotte Shop 2',
|
||||
products_ow: { connect: [id2] },
|
||||
products_oo: { connect: [id2] },
|
||||
products_mo: { connect: [id2] },
|
||||
products_om: { connect: [id2] },
|
||||
products_mm: { connect: [id2] },
|
||||
products_mw: { connect: [id2] },
|
||||
myCompo: {
|
||||
id: createdShop.myCompo.id,
|
||||
compo_products_ow: { connect: [id2] },
|
||||
compo_products_mw: { connect: [id2] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(name).toBe('Cazotte Shop 2');
|
||||
|
||||
const clonedShop = await strapi.entityService.findOne('api::shop.shop', id, {
|
||||
populate: populateShop,
|
||||
});
|
||||
|
||||
expect(clonedShop.myCompo.compo_products_mw).toMatchObject([{ id: id1 }, { id: id2 }]);
|
||||
expect(clonedShop.myCompo.compo_products_ow).toMatchObject({ id: id2 });
|
||||
expect(clonedShop.products_mm).toMatchObject([{ id: id1 }, { id: id2 }]);
|
||||
expect(clonedShop.products_mo).toMatchObject({ id: id2 });
|
||||
expect(clonedShop.products_mw).toMatchObject([{ id: id1 }, { id: id2 }]);
|
||||
expect(clonedShop.products_om).toMatchObject([{ id: id1 }, { id: id2 }]);
|
||||
expect(clonedShop.products_oo).toMatchObject({ id: id2 });
|
||||
expect(clonedShop.products_ow).toMatchObject({ id: id2 });
|
||||
});
|
||||
|
||||
test('Clone entity with relations and disconnect data', async () => {
|
||||
const createdShop = await createEntry(
|
||||
'shop',
|
||||
{
|
||||
name: 'Cazotte Shop',
|
||||
products_ow: { connect: [id1] },
|
||||
products_oo: { connect: [id1] },
|
||||
products_mo: { connect: [id1] },
|
||||
products_om: { connect: [id1, id2] },
|
||||
products_mm: { connect: [id1, id2] },
|
||||
products_mw: { connect: [id1, id2] },
|
||||
myCompo: {
|
||||
compo_products_ow: { connect: [id1] },
|
||||
compo_products_mw: { connect: [id1, id2] },
|
||||
},
|
||||
},
|
||||
['myCompo']
|
||||
);
|
||||
|
||||
const { id, name } = await cloneEntry('shop', createdShop.id, {
|
||||
name: 'Cazotte Shop 2',
|
||||
products_ow: { disconnect: [id1] },
|
||||
products_oo: { disconnect: [id1] },
|
||||
products_mo: { disconnect: [id1] },
|
||||
products_om: { disconnect: [id1] },
|
||||
products_mm: { disconnect: [id1] },
|
||||
products_mw: { disconnect: [id1] },
|
||||
myCompo: {
|
||||
id: createdShop.myCompo.id,
|
||||
compo_products_ow: { disconnect: [id1] },
|
||||
compo_products_mw: { disconnect: [id1] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(name).toBe('Cazotte Shop 2');
|
||||
|
||||
const clonedShop = await strapi.entityService.findOne('api::shop.shop', id, {
|
||||
populate: populateShop,
|
||||
});
|
||||
|
||||
expect(clonedShop.myCompo.compo_products_mw).toMatchObject([{ id: id2 }]);
|
||||
expect(clonedShop.myCompo.compo_products_ow).toBe(null);
|
||||
expect(clonedShop.products_mm).toMatchObject([{ id: id2 }]);
|
||||
expect(clonedShop.products_mo).toBe(null);
|
||||
expect(clonedShop.products_mw).toMatchObject([{ id: id2 }]);
|
||||
expect(clonedShop.products_om).toMatchObject([{ id: id2 }]);
|
||||
expect(clonedShop.products_oo).toBe(null);
|
||||
expect(clonedShop.products_ow).toBe(null);
|
||||
});
|
||||
|
||||
test('Clone entity with relations and disconnect data should not steal relations', async () => {
|
||||
const createdShop = await createEntry(
|
||||
'shop',
|
||||
{
|
||||
name: 'Cazotte Shop',
|
||||
products_ow: { connect: [id1] },
|
||||
products_oo: { connect: [id1] },
|
||||
products_mo: { connect: [id1] },
|
||||
products_om: { connect: [id1, id2] },
|
||||
products_mm: { connect: [id1, id2] },
|
||||
products_mw: { connect: [id1, id2] },
|
||||
myCompo: {
|
||||
compo_products_ow: { connect: [id1] },
|
||||
compo_products_mw: { connect: [id1, id2] },
|
||||
},
|
||||
},
|
||||
['myCompo']
|
||||
);
|
||||
|
||||
await cloneEntry('shop', createdShop.id, {
|
||||
name: 'Cazotte Shop 2',
|
||||
products_oo: { disconnect: [id1] },
|
||||
products_om: { disconnect: [id1] },
|
||||
});
|
||||
|
||||
const populatedCreatedShop = await strapi.entityService.findOne(
|
||||
'api::shop.shop',
|
||||
createdShop.id,
|
||||
{ populate: populateShop }
|
||||
);
|
||||
|
||||
expect(populatedCreatedShop.products_om).toMatchObject([{ id: id1 }]);
|
||||
expect(populatedCreatedShop.products_oo).toMatchObject({ id: id1 });
|
||||
});
|
||||
|
||||
test('Clone entity with relations and set data should not steal relations', async () => {
|
||||
const createdShop = await createEntry(
|
||||
'shop',
|
||||
{
|
||||
name: 'Cazotte Shop',
|
||||
products_ow: { connect: [id1] },
|
||||
products_oo: { connect: [id1] },
|
||||
products_mo: { connect: [id1] },
|
||||
products_om: { connect: [id1, id2] },
|
||||
products_mm: { connect: [id1, id2] },
|
||||
products_mw: { connect: [id1, id2] },
|
||||
myCompo: {
|
||||
compo_products_ow: { connect: [id1] },
|
||||
compo_products_mw: { connect: [id1, id2] },
|
||||
},
|
||||
},
|
||||
['myCompo']
|
||||
);
|
||||
|
||||
await cloneEntry('shop', createdShop.id, {
|
||||
name: 'Cazotte Shop 2',
|
||||
products_oo: { set: [id2] }, // id 1 should not be stolen from createdShop products_oo
|
||||
products_om: { set: [id2] }, // id 1 should not be stolen from createdShop products_om
|
||||
});
|
||||
|
||||
const populatedCreatedShop = await strapi.entityService.findOne(
|
||||
'api::shop.shop',
|
||||
createdShop.id,
|
||||
{ populate: populateShop }
|
||||
);
|
||||
|
||||
expect(populatedCreatedShop.products_om).toMatchObject([{ id: id1 }]);
|
||||
expect(populatedCreatedShop.products_oo).toMatchObject({ id: id1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -237,7 +237,15 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
|
||||
|
||||
const onPost = useCallback(
|
||||
async (body, trackerProperty) => {
|
||||
const endPoint = `${getRequestUrl(`collection-types/${slug}`)}${rawQuery}`;
|
||||
/**
|
||||
* If we're cloning we want to post directly to this endpoint
|
||||
* so that the relations even if they're not listed in the EditView
|
||||
* are correctly attached to the entry.
|
||||
*/
|
||||
const endPoint =
|
||||
typeof origin === 'string'
|
||||
? `${getRequestUrl(`collection-types/${slug}/clone/${origin}`)}${rawQuery}`
|
||||
: `${getRequestUrl(`collection-types/${slug}`)}${rawQuery}`;
|
||||
try {
|
||||
// Show a loading button in the EditView/Header.js && lock the app => no navigation
|
||||
dispatch(setStatus('submit-pending'));
|
||||
@ -272,6 +280,7 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
|
||||
}
|
||||
},
|
||||
[
|
||||
origin,
|
||||
cleanReceivedData,
|
||||
displayErrors,
|
||||
replace,
|
||||
|
||||
@ -1,163 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { DynamicTable as Table, useStrapiApp } from '@strapi/helper-plugin';
|
||||
import getReviewWorkflowsColumn from 'ee_else_ce/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/getTableColumn';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { INJECT_COLUMN_IN_TABLE } from '../../../exposedHooks';
|
||||
import { selectDisplayedHeaders } from '../../pages/ListView/selectors';
|
||||
import { getTrad } from '../../utils';
|
||||
|
||||
import BulkActionsBar from './BulkActionsBar';
|
||||
import { PublicationState } from './CellContent/PublicationState/PublicationState';
|
||||
import ConfirmDialogDelete from './ConfirmDialogDelete';
|
||||
import TableRows from './TableRows';
|
||||
|
||||
const DynamicTable = ({
|
||||
canCreate,
|
||||
canDelete,
|
||||
canPublish,
|
||||
contentTypeName,
|
||||
action,
|
||||
isBulkable,
|
||||
isLoading,
|
||||
onConfirmDelete,
|
||||
onConfirmDeleteAll,
|
||||
onConfirmPublishAll,
|
||||
onConfirmUnpublishAll,
|
||||
layout,
|
||||
rows,
|
||||
}) => {
|
||||
const { runHookWaterfall } = useStrapiApp();
|
||||
const hasDraftAndPublish = layout.contentType.options?.draftAndPublish ?? false;
|
||||
const { formatMessage } = useIntl();
|
||||
const displayedHeaders = useSelector(selectDisplayedHeaders);
|
||||
|
||||
const tableHeaders = useMemo(() => {
|
||||
const headers = runHookWaterfall(INJECT_COLUMN_IN_TABLE, {
|
||||
displayedHeaders,
|
||||
layout,
|
||||
});
|
||||
|
||||
const formattedHeaders = headers.displayedHeaders.map((header) => {
|
||||
const { fieldSchema, metadatas, name } = header;
|
||||
|
||||
return {
|
||||
...header,
|
||||
metadatas: {
|
||||
...metadatas,
|
||||
label: formatMessage({
|
||||
id: getTrad(`containers.ListPage.table-headers.${name}`),
|
||||
defaultMessage: metadatas.label,
|
||||
}),
|
||||
},
|
||||
name: fieldSchema.type === 'relation' ? `${name}.${metadatas.mainField.name}` : name,
|
||||
};
|
||||
});
|
||||
|
||||
if (hasDraftAndPublish) {
|
||||
formattedHeaders.push({
|
||||
key: '__published_at_temp_key__',
|
||||
name: 'publishedAt',
|
||||
fieldSchema: {
|
||||
type: 'custom',
|
||||
},
|
||||
metadatas: {
|
||||
label: formatMessage({
|
||||
id: getTrad(`containers.ListPage.table-headers.publishedAt`),
|
||||
defaultMessage: 'publishedAt',
|
||||
}),
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
},
|
||||
cellFormatter({ publishedAt }) {
|
||||
return <PublicationState isPublished={!!publishedAt} />;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// this should not exist. Ideally we would use registerHook() similar to what has been done
|
||||
// in the i18n plugin. In order to do that review-workflows should have been a plugin. In
|
||||
// a future iteration we need to find a better pattern.
|
||||
|
||||
// In CE this will return null - in EE a column definition including the custom formatting component.
|
||||
const reviewWorkflowColumn = getReviewWorkflowsColumn(layout);
|
||||
|
||||
if (reviewWorkflowColumn) {
|
||||
formattedHeaders.push(reviewWorkflowColumn);
|
||||
}
|
||||
|
||||
return formattedHeaders;
|
||||
}, [runHookWaterfall, displayedHeaders, layout, hasDraftAndPublish, formatMessage]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
components={{ ConfirmDialogDelete }}
|
||||
contentType={contentTypeName}
|
||||
action={action}
|
||||
isLoading={isLoading}
|
||||
headers={tableHeaders}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onOpenDeleteAllModalTrackedEvent="willBulkDeleteEntries"
|
||||
rows={rows}
|
||||
withBulkActions
|
||||
withMainAction={(canDelete || canPublish) && isBulkable}
|
||||
renderBulkActionsBar={({ selectedEntries, clearSelectedEntries }) => (
|
||||
<BulkActionsBar
|
||||
showPublish={canPublish && hasDraftAndPublish}
|
||||
showDelete={canDelete}
|
||||
onConfirmDeleteAll={onConfirmDeleteAll}
|
||||
onConfirmPublishAll={onConfirmPublishAll}
|
||||
onConfirmUnpublishAll={onConfirmUnpublishAll}
|
||||
selectedEntries={selectedEntries}
|
||||
clearSelectedEntries={clearSelectedEntries}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<TableRows
|
||||
canCreate={canCreate}
|
||||
canDelete={canDelete}
|
||||
contentType={layout.contentType}
|
||||
headers={tableHeaders}
|
||||
rows={rows}
|
||||
withBulkActions
|
||||
withMainAction={canDelete && isBulkable}
|
||||
/>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
DynamicTable.defaultProps = {
|
||||
action: undefined,
|
||||
};
|
||||
|
||||
DynamicTable.propTypes = {
|
||||
canCreate: PropTypes.bool.isRequired,
|
||||
canDelete: PropTypes.bool.isRequired,
|
||||
canPublish: PropTypes.bool.isRequired,
|
||||
contentTypeName: PropTypes.string.isRequired,
|
||||
action: PropTypes.node,
|
||||
isBulkable: PropTypes.bool.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
layout: PropTypes.exact({
|
||||
components: PropTypes.object.isRequired,
|
||||
contentType: PropTypes.shape({
|
||||
attributes: PropTypes.object.isRequired,
|
||||
metadatas: PropTypes.object.isRequired,
|
||||
layouts: PropTypes.shape({
|
||||
list: PropTypes.array.isRequired,
|
||||
}).isRequired,
|
||||
options: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
onConfirmDelete: PropTypes.func.isRequired,
|
||||
onConfirmDeleteAll: PropTypes.func.isRequired,
|
||||
onConfirmPublishAll: PropTypes.func.isRequired,
|
||||
onConfirmUnpublishAll: PropTypes.func.isRequired,
|
||||
rows: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export default DynamicTable;
|
||||
@ -241,14 +241,18 @@ const EditViewDataManagerProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const relationLoad = useCallback(({ target: { initialDataPath, modifiedDataPath, value } }) => {
|
||||
dispatch({
|
||||
type: 'LOAD_RELATION',
|
||||
modifiedDataPath,
|
||||
initialDataPath,
|
||||
value,
|
||||
});
|
||||
}, []);
|
||||
const relationLoad = useCallback(
|
||||
({ target: { initialDataPath, modifiedDataPath, value, modifiedDataOnly } }) => {
|
||||
dispatch({
|
||||
type: 'LOAD_RELATION',
|
||||
modifiedDataPath,
|
||||
initialDataPath,
|
||||
value,
|
||||
modifiedDataOnly,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addRepeatableComponentToField = dispatchAddComponent('ADD_REPEATABLE_COMPONENT_TO_FIELD');
|
||||
|
||||
|
||||
@ -138,17 +138,16 @@ const reducer = (state, action) =>
|
||||
__temp_key__: keys[index],
|
||||
}));
|
||||
|
||||
set(
|
||||
draftState,
|
||||
initialDataPath,
|
||||
uniqBy([...valuesWithKeys, ...initialDataRelations], 'id')
|
||||
);
|
||||
|
||||
/**
|
||||
* We need to set the value also on modifiedData, because initialData
|
||||
* and modifiedData need to stay in sync, so that the CM can compare
|
||||
* both states, to render the dirty UI state
|
||||
*/
|
||||
set(
|
||||
draftState,
|
||||
initialDataPath,
|
||||
uniqBy([...valuesWithKeys, ...initialDataRelations], 'id')
|
||||
);
|
||||
set(
|
||||
draftState,
|
||||
modifiedDataPath,
|
||||
|
||||
@ -17,12 +17,14 @@ import { connect, diffRelations, normalizeRelation, normalizeSearchResults, sele
|
||||
|
||||
export const RelationInputDataManager = ({
|
||||
error,
|
||||
entityId,
|
||||
componentId,
|
||||
isComponentRelation,
|
||||
editable,
|
||||
description,
|
||||
intlLabel,
|
||||
isCreatingEntry,
|
||||
isCloningEntry,
|
||||
isFieldAllowed,
|
||||
isFieldReadable,
|
||||
labelAction,
|
||||
@ -86,11 +88,12 @@ export const RelationInputDataManager = ({
|
||||
pageParams: {
|
||||
...defaultParams,
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
entityId: isCreatingEntry
|
||||
? undefined
|
||||
: isComponentRelation
|
||||
? componentId
|
||||
: initialData.id,
|
||||
entityId:
|
||||
isCreatingEntry || isCloningEntry
|
||||
? undefined
|
||||
: isComponentRelation
|
||||
? componentId
|
||||
: entityId,
|
||||
pageSize: SEARCH_RESULTS_TO_DISPLAY,
|
||||
},
|
||||
},
|
||||
@ -300,7 +303,7 @@ export const RelationInputDataManager = ({
|
||||
})} ${totalRelations > 0 ? `(${totalRelations})` : ''}`}
|
||||
labelAction={labelAction}
|
||||
labelLoadMore={
|
||||
!isCreatingEntry
|
||||
!isCreatingEntry || isCloningEntry
|
||||
? formatMessage({
|
||||
id: getTrad('relation.loadMore'),
|
||||
defaultMessage: 'Load More',
|
||||
@ -372,6 +375,7 @@ export const RelationInputDataManager = ({
|
||||
|
||||
RelationInputDataManager.defaultProps = {
|
||||
componentId: undefined,
|
||||
entityId: undefined,
|
||||
editable: true,
|
||||
error: undefined,
|
||||
description: '',
|
||||
@ -384,6 +388,7 @@ RelationInputDataManager.defaultProps = {
|
||||
|
||||
RelationInputDataManager.propTypes = {
|
||||
componentId: PropTypes.number,
|
||||
entityId: PropTypes.number,
|
||||
editable: PropTypes.bool,
|
||||
error: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
@ -393,6 +398,7 @@ RelationInputDataManager.propTypes = {
|
||||
values: PropTypes.object,
|
||||
}).isRequired,
|
||||
labelAction: PropTypes.element,
|
||||
isCloningEntry: PropTypes.bool.isRequired,
|
||||
isCreatingEntry: PropTypes.bool.isRequired,
|
||||
isComponentRelation: PropTypes.bool,
|
||||
isFieldAllowed: PropTypes.bool,
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
|
||||
import get from 'lodash/get';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { getRequestUrl } from '../../../utils';
|
||||
|
||||
@ -21,6 +22,16 @@ function useSelect({
|
||||
modifiedData,
|
||||
} = useCMEditViewDataManager();
|
||||
|
||||
/**
|
||||
* This is our cloning route because the EditView & CloneView share the same UI component
|
||||
* We need the origin ID to pre-load the relations into the modifiedData of the new
|
||||
* to-be-cloned entity.
|
||||
*/
|
||||
const { params } =
|
||||
useRouteMatch('/content-manager/collectionType/:collectionType/create/clone/:origin') ?? {};
|
||||
|
||||
const { origin } = params ?? {};
|
||||
|
||||
const isFieldAllowed = useMemo(() => {
|
||||
if (isUserAllowedToEditField === true) {
|
||||
return true;
|
||||
@ -54,9 +65,11 @@ function useSelect({
|
||||
componentId = get(modifiedData, fieldNameKeys.slice(0, -1))?.id;
|
||||
}
|
||||
|
||||
const entityId = origin || modifiedData.id;
|
||||
|
||||
// /content-manager/relations/[model]/[id]/[field-name]
|
||||
const relationFetchEndpoint = useMemo(() => {
|
||||
if (isCreatingEntry) {
|
||||
if (isCreatingEntry && !origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -69,8 +82,8 @@ function useSelect({
|
||||
: null;
|
||||
}
|
||||
|
||||
return getRequestUrl(`relations/${slug}/${modifiedData.id}/${name.split('.').at(-1)}`);
|
||||
}, [isCreatingEntry, componentUid, slug, modifiedData.id, name, componentId, fieldNameKeys]);
|
||||
return getRequestUrl(`relations/${slug}/${entityId}/${name.split('.').at(-1)}`);
|
||||
}, [isCreatingEntry, origin, componentUid, slug, entityId, name, componentId, fieldNameKeys]);
|
||||
|
||||
// /content-manager/relations/[model]/[field-name]
|
||||
const relationSearchEndpoint = useMemo(() => {
|
||||
@ -82,6 +95,7 @@ function useSelect({
|
||||
}, [componentUid, slug, name]);
|
||||
|
||||
return {
|
||||
entityId,
|
||||
componentId,
|
||||
isComponentRelation: Boolean(componentUid),
|
||||
queryInfos: {
|
||||
@ -91,6 +105,7 @@ function useSelect({
|
||||
relation: relationFetchEndpoint,
|
||||
},
|
||||
},
|
||||
isCloningEntry: Boolean(origin),
|
||||
isCreatingEntry,
|
||||
isFieldAllowed,
|
||||
isFieldReadable,
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import useSelect from '../select';
|
||||
|
||||
@ -21,10 +24,22 @@ const CM_DATA_FIXTURE = {
|
||||
},
|
||||
};
|
||||
|
||||
function setup(props) {
|
||||
/**
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string[] | undefined} initialEntries
|
||||
* @returns {Promise<import('@testing-library/react-hooks').RenderHookResult>}
|
||||
*/
|
||||
function setup(props, initialEntries) {
|
||||
return new Promise((resolve) => {
|
||||
act(() => {
|
||||
resolve(renderHook(() => useSelect(props)));
|
||||
resolve(
|
||||
renderHook(() => useSelect(props), {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -278,4 +293,32 @@ describe('RelationInputDataManager | select', () => {
|
||||
|
||||
expect(result.current.componentId).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('Cloning entities', () => {
|
||||
it('should return isCloningEntry if on the cloning route', async () => {
|
||||
const { result } = await setup(
|
||||
{
|
||||
...SELECT_ATTR_FIXTURE,
|
||||
isCloningEntity: true,
|
||||
},
|
||||
['/content-manager/collectionType/test-content/create/clone/test-id']
|
||||
);
|
||||
|
||||
expect(result.current.isCloningEntry).toBe(true);
|
||||
});
|
||||
|
||||
it('should return the endpoint of the origin id if on the cloning route', async () => {
|
||||
const { result } = await setup(
|
||||
{
|
||||
...SELECT_ATTR_FIXTURE,
|
||||
isCloningEntity: true,
|
||||
},
|
||||
['/content-manager/collectionType/test-content/create/clone/test-id']
|
||||
);
|
||||
|
||||
expect(result.current.queryInfos.endpoints.relation).toMatchInlineSnapshot(
|
||||
`"/content-manager/relations/slug/test-id/test"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('react').EffectCallback} effect
|
||||
* @returns void
|
||||
*/
|
||||
export const useOnce = (effect) => useEffect(effect, emptyDeps);
|
||||
|
||||
/**
|
||||
* @type {import('react').DependencyList}
|
||||
*/
|
||||
const emptyDeps = [];
|
||||
@ -1,10 +1,11 @@
|
||||
import React, { memo } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, ContentLayout, Flex, Grid, GridItem, Main } from '@strapi/design-system';
|
||||
import { Main, ContentLayout, Grid, GridItem, Flex, Box } from '@strapi/design-system';
|
||||
import {
|
||||
CheckPermissions,
|
||||
LinkButton,
|
||||
LoadingIndicatorPage,
|
||||
useNotification,
|
||||
useTracking,
|
||||
} from '@strapi/helper-plugin';
|
||||
import { Layer, Pencil } from '@strapi/icons';
|
||||
@ -12,6 +13,7 @@ import InformationBox from 'ee_else_ce/content-manager/pages/EditView/Informatio
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { selectAdminPermissions } from '../../../pages/App/selectors';
|
||||
import { InjectionZone } from '../../../shared/components';
|
||||
@ -26,7 +28,8 @@ import DeleteLink from './DeleteLink';
|
||||
import DraftAndPublishBadge from './DraftAndPublishBadge';
|
||||
import GridRow from './GridRow';
|
||||
import Header from './Header';
|
||||
import { selectAttributesLayout, selectCurrentLayout, selectCustomFieldUids } from './selectors';
|
||||
import { useOnce } from './hooks/useOnce';
|
||||
import { selectCurrentLayout, selectAttributesLayout, selectCustomFieldUids } from './selectors';
|
||||
import { getFieldsActionMatchingPermissions } from './utils';
|
||||
|
||||
// TODO: this seems suspicious
|
||||
@ -37,6 +40,24 @@ const EditView = ({ allowedActions, isSingleType, goBack, slug, id, origin, user
|
||||
const { trackUsage } = useTracking();
|
||||
const { formatMessage } = useIntl();
|
||||
const permissions = useSelector(selectAdminPermissions);
|
||||
const location = useLocation();
|
||||
const toggleNotification = useNotification();
|
||||
|
||||
useOnce(() => {
|
||||
/**
|
||||
* We only ever want to fire the notification once otherwise
|
||||
* whenever the app re-renders it'll pop up regardless of
|
||||
* what we do because the state comes from react-router-dom
|
||||
*/
|
||||
if (location?.state && 'error' in location.state) {
|
||||
toggleNotification({
|
||||
type: 'warning',
|
||||
message: location.state.error,
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const { layout, formattedContentTypeLayout, customFieldUids } = useSelector((state) => ({
|
||||
layout: selectCurrentLayout(state),
|
||||
formattedContentTypeLayout: selectAttributesLayout(state),
|
||||
@ -261,4 +282,4 @@ EditView.propTypes = {
|
||||
userPermissions: PropTypes.array,
|
||||
};
|
||||
|
||||
export default memo(EditView);
|
||||
export default EditView;
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { checkIfAttributeIsDisplayable } from '../../../../utils';
|
||||
|
||||
const getAllAllowedHeaders = (attributes) => {
|
||||
const allowedAttributes = Object.keys(attributes).reduce((acc, current) => {
|
||||
const attribute = attributes[current];
|
||||
|
||||
if (checkIfAttributeIsDisplayable(attribute)) {
|
||||
acc.push(current);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return allowedAttributes.sort();
|
||||
};
|
||||
|
||||
export default getAllAllowedHeaders;
|
||||
@ -1,21 +0,0 @@
|
||||
import getAllAllowedHeaders from '../getAllAllowedHeader';
|
||||
|
||||
describe('CONTENT MANAGER | containers | ListView | utils | getAllAllowedHeaders', () => {
|
||||
it('should return a sorted array containing all the displayed fields', () => {
|
||||
const attributes = {
|
||||
addresse: {
|
||||
type: 'relation',
|
||||
relationType: 'morph',
|
||||
},
|
||||
test: {
|
||||
type: 'string',
|
||||
},
|
||||
first: {
|
||||
type: 'relation',
|
||||
relationType: 'manyToMany',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getAllAllowedHeaders(attributes)).toEqual(['first', 'test']);
|
||||
});
|
||||
});
|
||||
@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Flex } from '@strapi/design-system';
|
||||
import { PageSizeURLQuery, PaginationURLQuery } from '@strapi/helper-plugin';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const PaginationFooter = ({ pagination }) => {
|
||||
return (
|
||||
<Box paddingTop={4}>
|
||||
<Flex alignItems="flex-end" justifyContent="space-between">
|
||||
<PageSizeURLQuery trackedEvent="willChangeNumberOfEntriesPerPage" />
|
||||
<PaginationURLQuery pagination={pagination} />
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
PaginationFooter.defaultProps = {
|
||||
pagination: {
|
||||
pageCount: 0,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
PaginationFooter.propTypes = {
|
||||
pagination: PropTypes.shape({
|
||||
page: PropTypes.number,
|
||||
pageCount: PropTypes.number,
|
||||
pageSize: PropTypes.number,
|
||||
total: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
|
||||
export default PaginationFooter;
|
||||
@ -16,7 +16,7 @@ import { useIntl } from 'react-intl';
|
||||
import { useQuery } from 'react-query';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { getRequestUrl, getTrad } from '../../../../utils';
|
||||
import { getRequestUrl, getTrad } from '../../../../../utils';
|
||||
import CellValue from '../CellValue';
|
||||
|
||||
const TypographyMaxWidth = styled(Typography)`
|
||||
@ -1,7 +1,7 @@
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import isNumber from 'lodash/isNumber';
|
||||
|
||||
import isFieldTypeNumber from '../../../../utils/isFieldTypeNumber';
|
||||
import isFieldTypeNumber from '../../../../../utils/isFieldTypeNumber';
|
||||
|
||||
import isSingleRelation from './isSingleRelation';
|
||||
|
||||
@ -5,9 +5,14 @@ import { ExclamationMarkCircle, Trash } from '@strapi/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import InjectionZoneList from '../../InjectionZoneList';
|
||||
import InjectionZoneList from '../../../../components/InjectionZoneList';
|
||||
|
||||
const ConfirmDialogDelete = ({ isConfirmButtonLoading, isOpen, onToggleDialog, onConfirm }) => {
|
||||
export const ConfirmDialogDelete = ({
|
||||
isConfirmButtonLoading,
|
||||
isOpen,
|
||||
onToggleDialog,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@ -70,5 +75,3 @@ ConfirmDialogDelete.propTypes = {
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
onToggleDialog: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ConfirmDialogDelete;
|
||||
@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Dialog, DialogBody, DialogFooter, Flex, Typography, Button } from '@strapi/design-system';
|
||||
import { ExclamationMarkCircle, Trash } from '@strapi/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import InjectionZoneList from '../../../../components/InjectionZoneList';
|
||||
import { getTrad } from '../../../../utils';
|
||||
|
||||
export const ConfirmDialogDeleteAll = ({
|
||||
isConfirmButtonLoading,
|
||||
isOpen,
|
||||
onToggleDialog,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onClose={onToggleDialog}
|
||||
title={formatMessage({
|
||||
id: 'app.components.ConfirmDialog.title',
|
||||
defaultMessage: 'Confirmation',
|
||||
})}
|
||||
labelledBy="confirmation"
|
||||
describedBy="confirm-description"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<DialogBody icon={<ExclamationMarkCircle />}>
|
||||
<Flex direction="column" alignItems="stretch" gap={2}>
|
||||
<Flex justifyContent="center">
|
||||
<Typography id="confirm-description">
|
||||
{formatMessage({
|
||||
id: getTrad('popUpWarning.bodyMessage.contentType.delete.all'),
|
||||
defaultMessage: 'Are you sure you want to delete these entries?',
|
||||
})}
|
||||
</Typography>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<InjectionZoneList area="contentManager.listView.deleteModalAdditionalInfos" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
startAction={
|
||||
<Button onClick={onToggleDialog} variant="tertiary">
|
||||
{formatMessage({
|
||||
id: 'app.components.Button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
</Button>
|
||||
}
|
||||
endAction={
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
variant="danger-light"
|
||||
startIcon={<Trash />}
|
||||
id="confirm-delete"
|
||||
loading={isConfirmButtonLoading}
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'app.components.Button.confirm',
|
||||
defaultMessage: 'Confirm',
|
||||
})}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
ConfirmDialogDeleteAll.propTypes = {
|
||||
isConfirmButtonLoading: PropTypes.bool.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
onToggleDialog: PropTypes.func.isRequired,
|
||||
};
|
||||
@ -1,18 +1,16 @@
|
||||
import React, { memo } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Option, Select } from '@strapi/design-system';
|
||||
import { Select, Option, Box } from '@strapi/design-system';
|
||||
import { useTracking } from '@strapi/helper-plugin';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import getTrad from '../../../utils/getTrad';
|
||||
import { onChangeListHeaders } from '../actions';
|
||||
import { selectDisplayedHeaders } from '../selectors';
|
||||
import { getTrad, checkIfAttributeIsDisplayable } from '../../../../utils';
|
||||
import { onChangeListHeaders } from '../../actions';
|
||||
import { selectDisplayedHeaders } from '../../selectors';
|
||||
|
||||
import getAllAllowedHeaders from './utils/getAllAllowedHeader';
|
||||
|
||||
const FieldPicker = ({ layout }) => {
|
||||
export const FieldPicker = ({ layout }) => {
|
||||
const dispatch = useDispatch();
|
||||
const displayedHeaders = useSelector(selectDisplayedHeaders);
|
||||
const { trackUsage } = useTracking();
|
||||
@ -26,6 +24,7 @@ const FieldPicker = ({ layout }) => {
|
||||
intlLabel: { id: metadatas.label, defaultMessage: metadatas.label },
|
||||
};
|
||||
});
|
||||
|
||||
const values = displayedHeaders.map(({ name }) => name);
|
||||
|
||||
const handleChange = (updatedValues) => {
|
||||
@ -94,4 +93,16 @@ FieldPicker.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default memo(FieldPicker);
|
||||
const getAllAllowedHeaders = (attributes) => {
|
||||
const allowedAttributes = Object.keys(attributes).reduce((acc, current) => {
|
||||
const attribute = attributes[current];
|
||||
|
||||
if (checkIfAttributeIsDisplayable(attribute)) {
|
||||
acc.push(current);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return allowedAttributes.sort();
|
||||
};
|
||||
@ -1,17 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
import { BaseCheckbox, Box, Flex, IconButton, Tbody, Td, Tr } from '@strapi/design-system';
|
||||
import { onRowClick, stopPropagation, useTracking } from '@strapi/helper-plugin';
|
||||
import { Duplicate, Pencil, Trash } from '@strapi/icons';
|
||||
import { BaseCheckbox, IconButton, Tbody, Td, Tr, Flex } from '@strapi/design-system';
|
||||
import { useTracking, useFetchClient, useAPIErrorHandler } from '@strapi/helper-plugin';
|
||||
import { Trash, Duplicate, Pencil } from '@strapi/icons';
|
||||
import { AxiosError } from 'axios';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import { getFullName } from '../../../../utils';
|
||||
import { usePluginsQueryParams } from '../../../hooks';
|
||||
import { getFullName } from '../../../../../utils';
|
||||
import { usePluginsQueryParams } from '../../../../hooks';
|
||||
import { getTrad } from '../../../../utils';
|
||||
import CellContent from '../CellContent';
|
||||
|
||||
const TableRows = ({
|
||||
export const TableRows = ({
|
||||
canCreate,
|
||||
canDelete,
|
||||
contentType,
|
||||
@ -23,19 +25,63 @@ const TableRows = ({
|
||||
withBulkActions,
|
||||
rows,
|
||||
}) => {
|
||||
const {
|
||||
push,
|
||||
location: { pathname },
|
||||
} = useHistory();
|
||||
const { push, location } = useHistory();
|
||||
const { pathname } = location;
|
||||
const { formatMessage } = useIntl();
|
||||
const { post } = useFetchClient();
|
||||
|
||||
const { trackUsage } = useTracking();
|
||||
const pluginsQueryParams = usePluginsQueryParams();
|
||||
const { formatAPIError } = useAPIErrorHandler(getTrad);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns void
|
||||
*/
|
||||
const handleRowClick = (id) => () => {
|
||||
if (!withBulkActions) return;
|
||||
|
||||
trackUsage('willEditEntryFromList');
|
||||
push({
|
||||
pathname: `${pathname}/${id}`,
|
||||
state: { from: pathname },
|
||||
search: pluginsQueryParams,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloneClick = (id) => async () => {
|
||||
try {
|
||||
const { data } = await post(
|
||||
`/content-manager/collection-types/${contentType.uid}/auto-clone/${id}?${pluginsQueryParams}`
|
||||
);
|
||||
|
||||
if ('id' in data) {
|
||||
push({
|
||||
pathname: `${pathname}/${data.id}`,
|
||||
state: { from: pathname },
|
||||
search: pluginsQueryParams,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
push({
|
||||
pathname: `${pathname}/create/clone/${id}`,
|
||||
state: { from: pathname, error: formatAPIError(err) },
|
||||
search: pluginsQueryParams,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Table Cells with actions e.g edit, delete, duplicate have `stopPropagation`
|
||||
* to prevent the row from being selected.
|
||||
*/
|
||||
return (
|
||||
<Tbody>
|
||||
{rows.map((data, index) => {
|
||||
const isChecked = entriesToDelete.findIndex((id) => id === data.id) !== -1;
|
||||
const isChecked = entriesToDelete.includes(data.id);
|
||||
const itemLineText = formatMessage(
|
||||
{
|
||||
id: 'content-manager.components.DynamicTable.row-line',
|
||||
@ -46,21 +92,12 @@ const TableRows = ({
|
||||
|
||||
return (
|
||||
<Tr
|
||||
cursor={withBulkActions ? 'pointer' : 'default'}
|
||||
key={data.id}
|
||||
{...onRowClick({
|
||||
fn() {
|
||||
trackUsage('willEditEntryFromList');
|
||||
push({
|
||||
pathname: `${pathname}/${data.id}`,
|
||||
state: { from: pathname },
|
||||
search: pluginsQueryParams,
|
||||
});
|
||||
},
|
||||
condition: withBulkActions,
|
||||
})}
|
||||
onClick={handleRowClick(data.id)}
|
||||
>
|
||||
{withMainAction && (
|
||||
<Td {...stopPropagation}>
|
||||
<Td onClick={(e) => e.stopPropagation()}>
|
||||
<BaseCheckbox
|
||||
aria-label={formatMessage(
|
||||
{
|
||||
@ -96,7 +133,7 @@ const TableRows = ({
|
||||
|
||||
{withBulkActions && (
|
||||
<Td>
|
||||
<Flex justifyContent="end" {...stopPropagation}>
|
||||
<Flex as="span" justifyContent="end" gap={1} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton
|
||||
forwardedAs={Link}
|
||||
onClick={() => {
|
||||
@ -112,46 +149,40 @@ const TableRows = ({
|
||||
{ target: itemLineText }
|
||||
)}
|
||||
noBorder
|
||||
icon={<Pencil />}
|
||||
/>
|
||||
>
|
||||
<Pencil />
|
||||
</IconButton>
|
||||
|
||||
{canCreate && (
|
||||
<Box paddingLeft={1}>
|
||||
<IconButton
|
||||
forwardedAs={Link}
|
||||
to={{
|
||||
pathname: `${pathname}/create/clone/${data.id}`,
|
||||
state: { from: pathname },
|
||||
search: pluginsQueryParams,
|
||||
}}
|
||||
label={formatMessage(
|
||||
{
|
||||
id: 'app.component.table.duplicate',
|
||||
defaultMessage: 'Duplicate {target}',
|
||||
},
|
||||
{ target: itemLineText }
|
||||
)}
|
||||
noBorder
|
||||
icon={<Duplicate />}
|
||||
/>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={handleCloneClick(data.id)}
|
||||
label={formatMessage(
|
||||
{
|
||||
id: 'app.component.table.duplicate',
|
||||
defaultMessage: 'Duplicate {target}',
|
||||
},
|
||||
{ target: itemLineText }
|
||||
)}
|
||||
noBorder
|
||||
>
|
||||
<Duplicate />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<Box paddingLeft={1}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
trackUsage('willDeleteEntryFromList');
|
||||
onClickDelete(data.id);
|
||||
}}
|
||||
label={formatMessage(
|
||||
{ id: 'global.delete-target', defaultMessage: 'Delete {target}' },
|
||||
{ target: itemLineText }
|
||||
)}
|
||||
noBorder
|
||||
icon={<Trash />}
|
||||
/>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
trackUsage('willDeleteEntryFromList');
|
||||
onClickDelete(data.id);
|
||||
}}
|
||||
label={formatMessage(
|
||||
{ id: 'global.delete-target', defaultMessage: 'Delete {target}' },
|
||||
{ target: itemLineText }
|
||||
)}
|
||||
noBorder
|
||||
>
|
||||
<Trash />
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
</Td>
|
||||
@ -188,5 +219,3 @@ TableRows.propTypes = {
|
||||
withBulkActions: PropTypes.bool,
|
||||
withMainAction: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TableRows;
|
||||
@ -1,28 +1,35 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
ActionLayout,
|
||||
IconButton,
|
||||
Main,
|
||||
Box,
|
||||
ActionLayout,
|
||||
Button,
|
||||
ContentLayout,
|
||||
HeaderLayout,
|
||||
IconButton,
|
||||
Main,
|
||||
useNotifyAT,
|
||||
Flex,
|
||||
Typography,
|
||||
Status,
|
||||
} from '@strapi/design-system';
|
||||
import {
|
||||
CheckPermissions,
|
||||
getYupInnerErrors,
|
||||
Link,
|
||||
NoPermissions,
|
||||
CheckPermissions,
|
||||
SearchURLQuery,
|
||||
useAPIErrorHandler,
|
||||
useFetchClient,
|
||||
useFocusWhenNavigate,
|
||||
useNotification,
|
||||
useQueryParams,
|
||||
useNotification,
|
||||
useRBACProvider,
|
||||
useTracking,
|
||||
Link,
|
||||
useAPIErrorHandler,
|
||||
getYupInnerErrors,
|
||||
useStrapiApp,
|
||||
DynamicTable,
|
||||
PaginationURLQuery,
|
||||
PageSizeURLQuery,
|
||||
} from '@strapi/helper-plugin';
|
||||
import { ArrowLeft, Cog, Plus } from '@strapi/icons';
|
||||
import axios from 'axios';
|
||||
@ -32,20 +39,22 @@ import { stringify } from 'qs';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation } from 'react-query';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import { Link as ReactRouterLink, useHistory, useLocation } from 'react-router-dom';
|
||||
import { useHistory, useLocation, Link as ReactRouterLink } from 'react-router-dom';
|
||||
import { bindActionCreators, compose } from 'redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { INJECT_COLUMN_IN_TABLE } from '../../../exposedHooks';
|
||||
import { selectAdminPermissions } from '../../../pages/App/selectors';
|
||||
import { InjectionZone } from '../../../shared/components';
|
||||
import AttributeFilter from '../../components/AttributeFilter';
|
||||
import DynamicTable from '../../components/DynamicTable';
|
||||
import { createYupSchema, getRequestUrl, getTrad } from '../../utils';
|
||||
|
||||
import { getData, getDataSucceeded, onChangeListHeaders, onResetListHeaders } from './actions';
|
||||
import FieldPicker from './FieldPicker';
|
||||
import PaginationFooter from './PaginationFooter';
|
||||
import makeSelectListView from './selectors';
|
||||
import { ConfirmDialogDelete } from './components/ConfirmDialogDelete';
|
||||
import { ConfirmDialogDeleteAll } from './components/ConfirmDialogDeleteAll';
|
||||
import { FieldPicker } from './components/FieldPicker';
|
||||
import { TableRows } from './components/TableRows';
|
||||
import makeSelectListView, { selectDisplayedHeaders } from './selectors';
|
||||
import { buildQueryString } from './utils';
|
||||
|
||||
const ConfigureLayoutBox = styled(Box)`
|
||||
@ -72,6 +81,8 @@ function ListView({
|
||||
const { total } = pagination;
|
||||
const { contentType } = layout;
|
||||
const {
|
||||
info,
|
||||
options,
|
||||
metadatas,
|
||||
settings: { bulkable: isBulkable, filterable: isFilterable, searchable: isSearchable },
|
||||
} = contentType;
|
||||
@ -79,8 +90,8 @@ function ListView({
|
||||
const toggleNotification = useNotification();
|
||||
const { trackUsage } = useTracking();
|
||||
const { refetchPermissions } = useRBACProvider();
|
||||
const trackUsageRef = useRef(trackUsage);
|
||||
const fetchPermissionsRef = useRef(refetchPermissions);
|
||||
const trackUsageRef = React.useRef(trackUsage);
|
||||
const fetchPermissionsRef = React.useRef(refetchPermissions);
|
||||
const { notifyStatus } = useNotifyAT();
|
||||
const { formatAPIError } = useAPIErrorHandler(getTrad);
|
||||
const permissions = useSelector(selectAdminPermissions);
|
||||
@ -94,7 +105,7 @@ function ListView({
|
||||
const { pathname } = useLocation();
|
||||
const { push } = useHistory();
|
||||
const { formatMessage } = useIntl();
|
||||
const hasDraftAndPublish = contentType.options?.draftAndPublish ?? false;
|
||||
const hasDraftAndPublish = options?.draftAndPublish || false;
|
||||
const fetchClient = useFetchClient();
|
||||
const { post, del } = fetchClient;
|
||||
|
||||
@ -145,9 +156,12 @@ function ListView({
|
||||
// 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
|
||||
const requestUrlRef = useRef('');
|
||||
const requestUrlRef = React.useRef('');
|
||||
|
||||
const fetchData = useCallback(
|
||||
/**
|
||||
* TODO: re-write all of this, it's a mess.
|
||||
*/
|
||||
const fetchData = React.useCallback(
|
||||
async (endPoint, source) => {
|
||||
getData();
|
||||
|
||||
@ -199,7 +213,7 @@ function ListView({
|
||||
[formatMessage, getData, getDataSucceeded, notifyStatus, push, toggleNotification, fetchClient]
|
||||
);
|
||||
|
||||
const handleConfirmDeleteAllData = useCallback(
|
||||
const handleConfirmDeleteAllData = React.useCallback(
|
||||
async (ids) => {
|
||||
try {
|
||||
await post(getRequestUrl(`collection-types/${slug}/actions/bulkDelete`), {
|
||||
@ -219,7 +233,7 @@ function ListView({
|
||||
[fetchData, params, slug, toggleNotification, formatAPIError, post]
|
||||
);
|
||||
|
||||
const handleConfirmDeleteData = useCallback(
|
||||
const handleConfirmDeleteData = React.useCallback(
|
||||
async (idToDelete) => {
|
||||
try {
|
||||
await del(getRequestUrl(`collection-types/${slug}/${idToDelete}`));
|
||||
@ -305,7 +319,7 @@ function ListView({
|
||||
return bulkUnpublishMutation.mutateAsync({ ids: selectedEntries });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
const CancelToken = axios.CancelToken;
|
||||
const source = CancelToken.source();
|
||||
|
||||
@ -328,10 +342,90 @@ function ListView({
|
||||
defaultMessage: 'Content',
|
||||
});
|
||||
const headerLayoutTitle = formatMessage({
|
||||
id: contentType.info.displayName,
|
||||
defaultMessage: contentType.info.displayName || defaultHeaderLayoutTitle,
|
||||
id: info.displayName,
|
||||
defaultMessage: info.displayName || defaultHeaderLayoutTitle,
|
||||
});
|
||||
|
||||
const { runHookWaterfall } = useStrapiApp();
|
||||
const displayedHeaders = useSelector(selectDisplayedHeaders);
|
||||
|
||||
const tableHeaders = React.useMemo(() => {
|
||||
const headers = runHookWaterfall(INJECT_COLUMN_IN_TABLE, {
|
||||
displayedHeaders,
|
||||
layout,
|
||||
});
|
||||
|
||||
const formattedHeaders = headers.displayedHeaders.map((header) => {
|
||||
const { metadatas } = header;
|
||||
|
||||
if (header.fieldSchema.type === 'relation') {
|
||||
const sortFieldValue = `${header.name}.${header.metadatas.mainField.name}`;
|
||||
|
||||
return {
|
||||
...header,
|
||||
metadatas: {
|
||||
...metadatas,
|
||||
label: formatMessage({
|
||||
id: getTrad(`containers.ListPage.table-headers.${header.name}`),
|
||||
defaultMessage: metadatas.label,
|
||||
}),
|
||||
},
|
||||
name: sortFieldValue,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...header,
|
||||
metadatas: {
|
||||
...metadatas,
|
||||
label: formatMessage({
|
||||
id: getTrad(`containers.ListPage.table-headers.${header.name}`),
|
||||
defaultMessage: metadatas.label,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!hasDraftAndPublish) {
|
||||
return formattedHeaders;
|
||||
}
|
||||
|
||||
return [
|
||||
...formattedHeaders,
|
||||
{
|
||||
key: '__published_at_temp_key__',
|
||||
name: 'publishedAt',
|
||||
fieldSchema: {
|
||||
type: 'custom',
|
||||
},
|
||||
metadatas: {
|
||||
label: formatMessage({
|
||||
id: getTrad(`containers.ListPage.table-headers.publishedAt`),
|
||||
defaultMessage: 'publishedAt',
|
||||
}),
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
},
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
cellFormatter(cellData) {
|
||||
const isPublished = cellData.publishedAt;
|
||||
const variant = isPublished ? 'success' : 'secondary';
|
||||
|
||||
return (
|
||||
<Status width="min-content" showBullet={false} variant={variant} size="S">
|
||||
<Typography fontWeight="bold" textColor={`${variant}700`}>
|
||||
{formatMessage({
|
||||
id: getTrad(`containers.List.${isPublished ? 'published' : 'draft'}`),
|
||||
defaultMessage: isPublished ? 'Published' : 'Draft',
|
||||
})}
|
||||
</Typography>
|
||||
</Status>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [runHookWaterfall, displayedHeaders, layout, hasDraftAndPublish, formatMessage]);
|
||||
|
||||
const subtitle = canRead
|
||||
? formatMessage(
|
||||
{
|
||||
@ -434,7 +528,7 @@ function ListView({
|
||||
)}
|
||||
<ContentLayout>
|
||||
{canRead ? (
|
||||
<>
|
||||
<Flex gap={4} direction="column" alignItems="stretch">
|
||||
<DynamicTable
|
||||
canCreate={canCreate}
|
||||
canDelete={canDelete}
|
||||
@ -449,10 +543,29 @@ function ListView({
|
||||
// FIXME: remove the layout props drilling
|
||||
layout={layout}
|
||||
rows={data}
|
||||
components={{ ConfirmDialogDelete, ConfirmDialogDeleteAll }}
|
||||
contentType={headerLayoutTitle}
|
||||
action={getCreateAction({ variant: 'secondary' })}
|
||||
/>
|
||||
<PaginationFooter pagination={{ pageCount: pagination?.pageCount || 1 }} />
|
||||
</>
|
||||
headers={tableHeaders}
|
||||
onOpenDeleteAllModalTrackedEvent="willBulkDeleteEntries"
|
||||
withBulkActions
|
||||
withMainAction={canDelete && isBulkable}
|
||||
>
|
||||
<TableRows
|
||||
canCreate={canCreate}
|
||||
canDelete={canDelete}
|
||||
contentType={contentType}
|
||||
headers={tableHeaders}
|
||||
rows={data}
|
||||
withBulkActions
|
||||
withMainAction={canDelete && isBulkable}
|
||||
/>
|
||||
</DynamicTable>
|
||||
<Flex alignItems="flex-end" justifyContent="space-between">
|
||||
<PageSizeURLQuery trackedEvent="willChangeNumberOfEntriesPerPage" />
|
||||
<PaginationURLQuery pagination={{ pageCount: pagination?.pageCount || 1 }} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<NoPermissions />
|
||||
)}
|
||||
@ -504,4 +617,4 @@ export function mapDispatchToProps(dispatch) {
|
||||
}
|
||||
const withConnect = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export default compose(withConnect)(memo(ListView, isEqual));
|
||||
export default compose(withConnect)(React.memo(ListView, isEqual));
|
||||
|
||||
@ -1,3 +1 @@
|
||||
// export { default as getAllAllowedHeaders } from './getAllAllowedHeaders';
|
||||
// export { default as getFirstSortableHeader } from './getFirstSortableHeader';
|
||||
export { default as buildQueryString } from './buildQueryString';
|
||||
|
||||
@ -34,7 +34,7 @@ export default memo(Permissions, (prev, next) => {
|
||||
// When we navigate from the EV to the LV using the menu the state is lost at some point
|
||||
// and this causes the component to rerender twice and firing requests twice
|
||||
// this hack prevents this
|
||||
// TODO at some point we will need to refacto the LV and migrate to react-query
|
||||
// TODO: at some point we will need to refactor the LV and migrate to react-query
|
||||
const propNamesThatHaveChanged = Object.keys(differenceBetweenRerenders).filter(
|
||||
(propName) => propName !== 'state'
|
||||
);
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
const { setCreatorFields, pipeAsync } = require('@strapi/utils');
|
||||
const { ApplicationError } = require('@strapi/utils').errors;
|
||||
|
||||
const { getService, pickWritableAttributes } = require('../utils');
|
||||
const { validateBulkActionInput } = require('./validation');
|
||||
const { hasProhibitedCloningFields, excludeNotCreatableFields } = require('./utils/clone');
|
||||
|
||||
module.exports = {
|
||||
async find(ctx) {
|
||||
@ -150,6 +152,62 @@ module.exports = {
|
||||
ctx.body = await permissionChecker.sanitizeOutput(updatedEntity);
|
||||
},
|
||||
|
||||
async clone(ctx) {
|
||||
const { userAbility, user } = ctx.state;
|
||||
const { model, sourceId: id } = ctx.params;
|
||||
const { body } = ctx.request;
|
||||
|
||||
const entityManager = getService('entity-manager');
|
||||
const permissionChecker = getService('permission-checker').create({ userAbility, model });
|
||||
|
||||
if (permissionChecker.cannot.create()) {
|
||||
return ctx.forbidden();
|
||||
}
|
||||
|
||||
const permissionQuery = await permissionChecker.sanitizedQuery.create(ctx.query);
|
||||
const populate = await getService('populate-builder')(model)
|
||||
.populateFromQuery(permissionQuery)
|
||||
.build();
|
||||
|
||||
const entity = await entityManager.findOne(id, model, { populate });
|
||||
|
||||
if (!entity) {
|
||||
return ctx.notFound();
|
||||
}
|
||||
|
||||
const pickWritables = pickWritableAttributes({ model });
|
||||
const pickPermittedFields = permissionChecker.sanitizeCreateInput;
|
||||
const setCreator = setCreatorFields({ user });
|
||||
const excludeNotCreatable = excludeNotCreatableFields(model, permissionChecker);
|
||||
|
||||
const sanitizeFn = pipeAsync(
|
||||
pickWritables,
|
||||
pickPermittedFields,
|
||||
setCreator,
|
||||
excludeNotCreatable
|
||||
);
|
||||
|
||||
const sanitizedBody = await sanitizeFn(body);
|
||||
|
||||
const clonedEntity = await entityManager.clone(entity, sanitizedBody, model);
|
||||
|
||||
ctx.body = await permissionChecker.sanitizeOutput(clonedEntity);
|
||||
},
|
||||
|
||||
async autoClone(ctx) {
|
||||
const { model } = ctx.params;
|
||||
|
||||
// Trying to automatically clone the entity and model has unique or relational fields
|
||||
if (hasProhibitedCloningFields(model)) {
|
||||
throw new ApplicationError(
|
||||
'Entity could not be cloned as it has unique and/or relational fields. ' +
|
||||
'Please edit those fields manually and save to complete the cloning.'
|
||||
);
|
||||
}
|
||||
|
||||
this.clone(ctx);
|
||||
},
|
||||
|
||||
async delete(ctx) {
|
||||
const { userAbility } = ctx.state;
|
||||
const { id, model } = ctx.params;
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
const { hasProhibitedCloningFields } = require('../clone');
|
||||
|
||||
describe('Populate', () => {
|
||||
const fakeModels = {
|
||||
simple: {
|
||||
modelName: 'Fake simple model',
|
||||
attributes: {
|
||||
text: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
simpleUnique: {
|
||||
modelName: 'Fake simple model',
|
||||
attributes: {
|
||||
text: {
|
||||
type: 'string',
|
||||
unique: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
component: {
|
||||
modelName: 'Fake component model',
|
||||
attributes: {
|
||||
componentAttrName: {
|
||||
type: 'component',
|
||||
component: 'simple',
|
||||
},
|
||||
},
|
||||
},
|
||||
componentUnique: {
|
||||
modelName: 'Fake component model',
|
||||
attributes: {
|
||||
componentAttrName: {
|
||||
type: 'component',
|
||||
component: 'simpleUnique',
|
||||
},
|
||||
},
|
||||
},
|
||||
dynZone: {
|
||||
modelName: 'Fake dynamic zone model',
|
||||
attributes: {
|
||||
dynZoneAttrName: {
|
||||
type: 'dynamiczone',
|
||||
components: ['simple', 'component'],
|
||||
},
|
||||
},
|
||||
},
|
||||
dynZoneUnique: {
|
||||
modelName: 'Fake dynamic zone model',
|
||||
attributes: {
|
||||
dynZoneAttrName: {
|
||||
type: 'dynamiczone',
|
||||
components: ['simple', 'componentUnique'],
|
||||
},
|
||||
},
|
||||
},
|
||||
relation: {
|
||||
modelName: 'Fake relation oneToMany model',
|
||||
attributes: {
|
||||
relationAttrName: {
|
||||
type: 'relation',
|
||||
relation: 'oneToMany',
|
||||
},
|
||||
},
|
||||
},
|
||||
media: {
|
||||
modelName: 'Fake media model',
|
||||
attributes: {
|
||||
mediaAttrName: {
|
||||
type: 'media',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('hasProhibitedCloningFields', () => {
|
||||
beforeEach(() => {
|
||||
global.strapi = {
|
||||
getModel: jest.fn((uid) => fakeModels[uid]),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('model without unique fields', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('simple');
|
||||
expect(hasProhibitedFields).toEqual(false);
|
||||
});
|
||||
|
||||
test('model with unique fields', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('simpleUnique');
|
||||
expect(hasProhibitedFields).toEqual(true);
|
||||
});
|
||||
|
||||
test('model with component', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('component');
|
||||
expect(hasProhibitedFields).toEqual(false);
|
||||
});
|
||||
|
||||
test('model with component & unique fields', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('componentUnique');
|
||||
expect(hasProhibitedFields).toEqual(true);
|
||||
});
|
||||
|
||||
test('model with component & unique fields', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('componentUnique');
|
||||
expect(hasProhibitedFields).toEqual(true);
|
||||
});
|
||||
|
||||
test('model with dynamic zone', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('dynZone');
|
||||
expect(hasProhibitedFields).toEqual(false);
|
||||
});
|
||||
|
||||
test('model with dynamic zone', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('dynZoneUnique');
|
||||
expect(hasProhibitedFields).toEqual(true);
|
||||
});
|
||||
|
||||
test('model with relation', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('relation');
|
||||
expect(hasProhibitedFields).toEqual(true);
|
||||
});
|
||||
|
||||
test('model with media', () => {
|
||||
const hasProhibitedFields = hasProhibitedCloningFields('media');
|
||||
expect(hasProhibitedFields).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
const { set } = require('lodash/fp');
|
||||
const strapiUtils = require('@strapi/utils');
|
||||
|
||||
const { isVisibleAttribute } = strapiUtils.contentTypes;
|
||||
|
||||
function isProhibitedRelation(model, attributeName) {
|
||||
// we don't care about createdBy, updatedBy, localizations etc.
|
||||
if (!isVisibleAttribute(model, attributeName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasProhibitedCloningFields = (uid) => {
|
||||
const model = strapi.getModel(uid);
|
||||
|
||||
return Object.keys(model.attributes).some((attributeName) => {
|
||||
const attribute = model.attributes[attributeName];
|
||||
|
||||
switch (attribute.type) {
|
||||
case 'relation':
|
||||
return isProhibitedRelation(model, attributeName);
|
||||
case 'component':
|
||||
return hasProhibitedCloningFields(attribute.component);
|
||||
case 'dynamiczone':
|
||||
return (attribute.components || []).some((componentUID) =>
|
||||
hasProhibitedCloningFields(componentUID)
|
||||
);
|
||||
case 'uid':
|
||||
return true;
|
||||
default:
|
||||
return attribute?.unique ?? false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterates all attributes of the content type, and removes the ones that are not creatable.
|
||||
* - If it's a relation, it sets the value to [] or null.
|
||||
* - If it's a regular attribute, it sets the value to null.
|
||||
* When cloning, if you don't set a field it will be copied from the original entry. So we need to
|
||||
* remove the fields that the user can't create.
|
||||
*/
|
||||
const excludeNotCreatableFields =
|
||||
(uid, permissionChecker) =>
|
||||
(body, path = []) => {
|
||||
const model = strapi.getModel(uid);
|
||||
const canCreate = (path) => permissionChecker.can.create(null, path);
|
||||
|
||||
return Object.keys(model.attributes).reduce((body, attributeName) => {
|
||||
const attribute = model.attributes[attributeName];
|
||||
const attributePath = [...path, attributeName].join('.');
|
||||
|
||||
// Ignore the attribute if it's not visible
|
||||
if (!isVisibleAttribute(model, attributeName)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
switch (attribute.type) {
|
||||
// Relation should be empty if the user can't create it
|
||||
case 'relation': {
|
||||
if (canCreate(attributePath)) return body;
|
||||
return set(attributePath, { set: [] }, body);
|
||||
}
|
||||
// Go deeper into the component
|
||||
case 'component': {
|
||||
return excludeNotCreatableFields(attribute.component, permissionChecker)(body, [
|
||||
...path,
|
||||
attributeName,
|
||||
]);
|
||||
}
|
||||
// Attribute should be null if the user can't create it
|
||||
default: {
|
||||
if (canCreate(attributePath)) return body;
|
||||
return set(attributePath, null, body);
|
||||
}
|
||||
}
|
||||
}, body);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
hasProhibitedCloningFields,
|
||||
excludeNotCreatableFields,
|
||||
};
|
||||
@ -231,6 +231,36 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/collection-types/:model/clone/:sourceId',
|
||||
handler: 'collection-types.clone',
|
||||
config: {
|
||||
middlewares: [routing],
|
||||
policies: [
|
||||
'admin::isAuthenticatedAdmin',
|
||||
{
|
||||
name: 'plugin::content-manager.hasPermissions',
|
||||
config: { actions: ['plugin::content-manager.explorer.create'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/collection-types/:model/auto-clone/:sourceId',
|
||||
handler: 'collection-types.autoClone',
|
||||
config: {
|
||||
middlewares: [routing],
|
||||
policies: [
|
||||
'admin::isAuthenticatedAdmin',
|
||||
{
|
||||
name: 'plugin::content-manager.hasPermissions',
|
||||
config: { actions: ['plugin::content-manager.explorer.create'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/collection-types/:model/:id',
|
||||
|
||||
@ -125,7 +125,29 @@ module.exports = ({ strapi }) => ({
|
||||
|
||||
return updatedEntity;
|
||||
},
|
||||
async clone(entity, body, uid) {
|
||||
const modelDef = strapi.getModel(uid);
|
||||
const populate = await buildDeepPopulate(uid);
|
||||
const publishData = { ...body };
|
||||
|
||||
if (hasDraftAndPublish(modelDef)) {
|
||||
publishData[PUBLISHED_AT_ATTRIBUTE] = null;
|
||||
}
|
||||
|
||||
const params = {
|
||||
data: publishData,
|
||||
populate,
|
||||
};
|
||||
|
||||
const clonedEntity = await strapi.entityService.clone(uid, entity.id, params);
|
||||
|
||||
// If relations were populated, relations count will be returned instead of the array of relations.
|
||||
if (isWebhooksPopulateRelationsEnabled(uid)) {
|
||||
return getDeepRelationsCount(clonedEntity, uid);
|
||||
}
|
||||
|
||||
return clonedEntity;
|
||||
},
|
||||
async delete(entity, uid) {
|
||||
const populate = await buildDeepPopulate(uid);
|
||||
const deletedEntity = await strapi.entityService.delete(uid, entity.id, { populate });
|
||||
|
||||
@ -80,6 +80,10 @@ const createRepository = (uid, db) => {
|
||||
return db.entityManager.updateMany(uid, params);
|
||||
},
|
||||
|
||||
clone(id, params) {
|
||||
return db.entityManager.clone(uid, id, params);
|
||||
},
|
||||
|
||||
delete(params) {
|
||||
return db.entityManager.delete(uid, params);
|
||||
},
|
||||
@ -111,6 +115,10 @@ const createRepository = (uid, db) => {
|
||||
return db.entityManager.deleteRelations(uid, id);
|
||||
},
|
||||
|
||||
cloneRelations(targetId, sourceId, params) {
|
||||
return db.entityManager.cloneRelations(uid, targetId, sourceId, params);
|
||||
},
|
||||
|
||||
populate(entity, populate) {
|
||||
return db.entityManager.populate(uid, entity, populate);
|
||||
},
|
||||
|
||||
@ -1,32 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
isUndefined,
|
||||
castArray,
|
||||
compact,
|
||||
isNil,
|
||||
has,
|
||||
isString,
|
||||
isInteger,
|
||||
pick,
|
||||
isPlainObject,
|
||||
isEmpty,
|
||||
isArray,
|
||||
isNull,
|
||||
uniqWith,
|
||||
isEqual,
|
||||
differenceWith,
|
||||
isNumber,
|
||||
map,
|
||||
difference,
|
||||
differenceWith,
|
||||
flow,
|
||||
has,
|
||||
isArray,
|
||||
isEmpty,
|
||||
isEqual,
|
||||
isInteger,
|
||||
isNil,
|
||||
isNull,
|
||||
isNumber,
|
||||
isPlainObject,
|
||||
isString,
|
||||
isUndefined,
|
||||
map,
|
||||
mergeWith,
|
||||
omit,
|
||||
pick,
|
||||
uniqBy,
|
||||
uniqWith,
|
||||
} = require('lodash/fp');
|
||||
|
||||
const { mapAsync } = require('@strapi/utils');
|
||||
const types = require('../types');
|
||||
const { createField } = require('../fields');
|
||||
const { createQueryBuilder } = require('../query');
|
||||
const { createRepository } = require('./entity-repository');
|
||||
const { deleteRelatedMorphOneRelationsAfterMorphToManyUpdate } = require('./morph-relations');
|
||||
const {
|
||||
isPolymorphic,
|
||||
isBidirectional,
|
||||
isAnyToOne,
|
||||
isOneToAny,
|
||||
@ -40,6 +46,11 @@ const {
|
||||
cleanOrderColumns,
|
||||
} = require('./regular-relations');
|
||||
const { relationsOrderer } = require('./relations-orderer');
|
||||
const {
|
||||
replaceRegularRelations,
|
||||
cloneRegularRelations,
|
||||
} = require('./relations/cloning/regular-relations');
|
||||
const { DatabaseError } = require('../errors');
|
||||
|
||||
const toId = (value) => value.id || value;
|
||||
const toIds = (value) => castArray(value || []).map(toId);
|
||||
@ -359,6 +370,61 @@ const createEntityManager = (db) => {
|
||||
return result;
|
||||
},
|
||||
|
||||
async clone(uid, cloneId, params = {}) {
|
||||
const states = await db.lifecycles.run('beforeCreate', uid, { params });
|
||||
|
||||
const metadata = db.metadata.get(uid);
|
||||
const { data } = params;
|
||||
|
||||
if (!isNil(data) && !isPlainObject(data)) {
|
||||
throw new Error('Create expects a data object');
|
||||
}
|
||||
|
||||
// TODO: Handle join columns?
|
||||
const entity = await this.findOne(uid, { where: { id: cloneId } });
|
||||
|
||||
const dataToInsert = flow(
|
||||
// Omit unwanted properties
|
||||
omit(['id', 'created_at', 'updated_at']),
|
||||
// Merge with provided data, set attribute to null if data attribute is null
|
||||
mergeWith(data || {}, (original, override) => (override === null ? override : original)),
|
||||
// Process data with metadata
|
||||
(entity) => processData(metadata, entity, { withDefaults: true })
|
||||
)(entity);
|
||||
|
||||
const res = await this.createQueryBuilder(uid).insert(dataToInsert).execute();
|
||||
|
||||
const id = res[0].id || res[0];
|
||||
|
||||
const trx = await strapi.db.transaction();
|
||||
try {
|
||||
const cloneAttrs = Object.entries(metadata.attributes).reduce((acc, [attrName, attr]) => {
|
||||
// TODO: handle components in the db layer
|
||||
if (attr.type === 'relation' && attr.joinTable && !attr.component) {
|
||||
acc.push(attrName);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
await this.cloneRelations(uid, id, cloneId, data, { cloneAttrs, transaction: trx.get() });
|
||||
await trx.commit();
|
||||
} catch (e) {
|
||||
await trx.rollback();
|
||||
await this.createQueryBuilder(uid).where({ id }).delete().execute();
|
||||
throw e;
|
||||
}
|
||||
|
||||
const result = await this.findOne(uid, {
|
||||
where: { id },
|
||||
select: params.select,
|
||||
populate: params.populate,
|
||||
});
|
||||
|
||||
await db.lifecycles.run('afterCreate', uid, { params, result }, states);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async delete(uid, params = {}) {
|
||||
const states = await db.lifecycles.run('beforeDelete', uid, { params });
|
||||
|
||||
@ -1167,6 +1233,73 @@ const createEntityManager = (db) => {
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: Clone polymorphic relations
|
||||
/**
|
||||
*
|
||||
* @param {string} uid - uid of the entity to clone
|
||||
* @param {number} targetId - id of the entity to clone into
|
||||
* @param {number} sourceId - id of the entity to clone from
|
||||
* @param {object} opt
|
||||
* @param {object} opt.cloneAttrs - key value pair of attributes to clone
|
||||
* @param {object} opt.transaction - transaction to use
|
||||
* @example cloneRelations('user', 3, 1, { cloneAttrs: ["comments"]})
|
||||
* @example cloneRelations('post', 5, 2, { cloneAttrs: ["comments", "likes"] })
|
||||
*/
|
||||
async cloneRelations(uid, targetId, sourceId, data, { cloneAttrs = [], transaction }) {
|
||||
const { attributes } = db.metadata.get(uid);
|
||||
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mapAsync(cloneAttrs, async (attrName) => {
|
||||
const attribute = attributes[attrName];
|
||||
|
||||
if (attribute.type !== 'relation') {
|
||||
throw new DatabaseError(
|
||||
`Attribute ${attrName} is not a relation attribute. Cloning relations is only supported for relation attributes.`
|
||||
);
|
||||
}
|
||||
|
||||
if (isPolymorphic(attribute)) {
|
||||
// TODO: add support for cloning polymorphic relations
|
||||
return;
|
||||
}
|
||||
|
||||
if (attribute.joinColumn) {
|
||||
// TODO: add support for cloning oneToMany relations on the owning side
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attribute.joinTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
let omitIds = [];
|
||||
if (has(attrName, data)) {
|
||||
const cleanRelationData = toAssocs(data[attrName]);
|
||||
|
||||
// Don't clone if the relation attr is being set
|
||||
if (cleanRelationData.set) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnected relations don't need to be cloned
|
||||
if (cleanRelationData.disconnect) {
|
||||
omitIds = toIds(cleanRelationData.disconnect);
|
||||
}
|
||||
}
|
||||
|
||||
if (isOneToAny(attribute) && isBidirectional(attribute)) {
|
||||
await replaceRegularRelations({ targetId, sourceId, attribute, omitIds, transaction });
|
||||
} else {
|
||||
await cloneRegularRelations({ targetId, sourceId, attribute, transaction });
|
||||
}
|
||||
});
|
||||
|
||||
await this.updateRelations(uid, targetId, data, { transaction });
|
||||
},
|
||||
|
||||
// TODO: add lifecycle events
|
||||
async populate(uid, entity, populate) {
|
||||
const entry = await this.findOne(uid, {
|
||||
|
||||
@ -379,9 +379,103 @@ const cleanOrderColumnsForOldDatabases = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this when a relation is added or removed and its inverse order column
|
||||
* needs to be re-calculated
|
||||
*
|
||||
* Example: In this following table
|
||||
*
|
||||
* | joinColumn | inverseJoinColumn | order | inverseOrder |
|
||||
* | --------------- | -------- | ----------- | ------------------ |
|
||||
* | 1 | 1 | 1 | 1 |
|
||||
* | 2 | 1 | 3 | 2 |
|
||||
* | 2 | 2 | 3 | 1 |
|
||||
*
|
||||
* You add a new relation { joinColumn: 1, inverseJoinColumn: 2 }
|
||||
*
|
||||
* | joinColumn | inverseJoinColumn | order | inverseOrder |
|
||||
* | --------------- | -------- | ----------- | ------------------ |
|
||||
* | 1 | 1 | 1 | 1 |
|
||||
* | 1 | 2 | 2 | 1 | <- inverseOrder should be 2
|
||||
* | 2 | 1 | 3 | 2 |
|
||||
* | 2 | 2 | 3 | 1 |
|
||||
*
|
||||
* This function would make such update, so all inverse order columns related
|
||||
* to the given id (1 in this example) are following a 1, 2, 3 sequence, without gap.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.id - entity id to find which inverse order column to clean
|
||||
* @param {Object} params.attribute - attribute of the relation
|
||||
* @param {Object} params.trx - knex transaction
|
||||
*
|
||||
*/
|
||||
|
||||
const cleanInverseOrderColumn = async ({ id, attribute, trx }) => {
|
||||
const con = strapi.db.connection;
|
||||
const { joinTable } = attribute;
|
||||
const { joinColumn, inverseJoinColumn, inverseOrderColumnName } = joinTable;
|
||||
|
||||
switch (strapi.db.dialect.client) {
|
||||
/*
|
||||
UPDATE `:joinTableName` AS `t1`
|
||||
JOIN (
|
||||
SELECT
|
||||
`inverseJoinColumn`,
|
||||
MAX(`:inverseOrderColumnName`) AS `max_inv_order`
|
||||
FROM `:joinTableName`
|
||||
GROUP BY `:inverseJoinColumn`
|
||||
) AS `t2`
|
||||
ON `t1`.`:inverseJoinColumn` = `t2`.`:inverseJoinColumn`
|
||||
SET `t1`.`:inverseOrderColumnNAme` = `t2`.`max_inv_order` + 1
|
||||
WHERE `t1`.`:joinColumnName` = :id;
|
||||
*/
|
||||
case 'mysql': {
|
||||
// Group by the inverse join column and get the max value of the inverse order column
|
||||
const subQuery = con(joinTable.name)
|
||||
.select(inverseJoinColumn.name)
|
||||
.max(inverseOrderColumnName, { as: 'max_inv_order' })
|
||||
.groupBy(inverseJoinColumn.name)
|
||||
.as('t2');
|
||||
|
||||
// Update ids with the new inverse order
|
||||
await con(`${joinTable.name} as t1`)
|
||||
.join(subQuery, `t1.${inverseJoinColumn.name}`, '=', `t2.${inverseJoinColumn.name}`)
|
||||
.where(joinColumn.name, id)
|
||||
.update({
|
||||
[inverseOrderColumnName]: con.raw('t2.max_inv_order + 1'),
|
||||
})
|
||||
.transacting(trx);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
/*
|
||||
UPDATE `:joinTableName` as `t1`
|
||||
SET `:inverseOrderColumnName` = (
|
||||
SELECT max(`:inverseOrderColumnName`) + 1
|
||||
FROM `:joinTableName` as `t2`
|
||||
WHERE t2.:inverseJoinColumn = t1.:inverseJoinColumn
|
||||
)
|
||||
WHERE `t1`.`:joinColumnName` = :id
|
||||
*/
|
||||
// New inverse order will be the max value + 1
|
||||
const selectMaxInverseOrder = con.raw(`max(${inverseOrderColumnName}) + 1`);
|
||||
|
||||
const subQuery = con(`${joinTable.name} as t2`)
|
||||
.select(selectMaxInverseOrder)
|
||||
.whereRaw(`t2.${inverseJoinColumn.name} = t1.${inverseJoinColumn.name}`);
|
||||
|
||||
await con(`${joinTable.name} as t1`)
|
||||
.where(`t1.${joinColumn.name}`, id)
|
||||
.update({ [inverseOrderColumnName]: subQuery })
|
||||
.transacting(trx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
deletePreviousOneToAnyRelations,
|
||||
deletePreviousAnyToOneRelations,
|
||||
deleteRelations,
|
||||
cleanOrderColumns,
|
||||
cleanInverseOrderColumn,
|
||||
};
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
const { cleanInverseOrderColumn } = require('../../regular-relations');
|
||||
|
||||
const replaceRegularRelations = async ({
|
||||
targetId,
|
||||
sourceId,
|
||||
attribute,
|
||||
omitIds,
|
||||
transaction: trx,
|
||||
}) => {
|
||||
const { joinTable } = attribute;
|
||||
const { joinColumn, inverseJoinColumn } = joinTable;
|
||||
|
||||
// We are effectively stealing the relation from the cloned entity
|
||||
await strapi.db.entityManager
|
||||
.createQueryBuilder(joinTable.name)
|
||||
.update({ [joinColumn.name]: targetId })
|
||||
.where({ [joinColumn.name]: sourceId })
|
||||
.where({ $not: { [inverseJoinColumn.name]: omitIds } })
|
||||
.onConflict([joinColumn.name, inverseJoinColumn.name])
|
||||
.ignore()
|
||||
.transacting(trx)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const cloneRegularRelations = async ({ targetId, sourceId, attribute, transaction: trx }) => {
|
||||
const { joinTable } = attribute;
|
||||
const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
|
||||
const connection = strapi.db.getConnection();
|
||||
|
||||
// Get the columns to select
|
||||
const columns = [joinColumn.name, inverseJoinColumn.name];
|
||||
if (orderColumnName) columns.push(orderColumnName);
|
||||
if (inverseOrderColumnName) columns.push(inverseOrderColumnName);
|
||||
if (joinTable.on) columns.push(...Object.keys(joinTable.on));
|
||||
|
||||
const selectStatement = connection
|
||||
.select(
|
||||
// Override joinColumn with the new id
|
||||
{ [joinColumn.name]: targetId },
|
||||
// The rest of columns will be the same
|
||||
...columns.slice(1)
|
||||
)
|
||||
.where(joinColumn.name, sourceId)
|
||||
.from(joinTable.name)
|
||||
.toSQL();
|
||||
|
||||
// Insert the clone relations
|
||||
await strapi.db.entityManager
|
||||
.createQueryBuilder(joinTable.name)
|
||||
.insert(
|
||||
strapi.db.connection.raw(
|
||||
`(${columns.join(',')}) ${selectStatement.sql}`,
|
||||
selectStatement.bindings
|
||||
)
|
||||
)
|
||||
.onConflict([joinColumn.name, inverseJoinColumn.name])
|
||||
.ignore()
|
||||
.transacting(trx)
|
||||
.execute();
|
||||
|
||||
// Clean the inverse order column
|
||||
if (inverseOrderColumnName) {
|
||||
await cleanInverseOrderColumn({
|
||||
id: targetId,
|
||||
attribute,
|
||||
trx,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
replaceRegularRelations,
|
||||
cloneRegularRelations,
|
||||
};
|
||||
@ -9,6 +9,8 @@ const _ = require('lodash/fp');
|
||||
const hasInversedBy = _.has('inversedBy');
|
||||
const hasMappedBy = _.has('mappedBy');
|
||||
|
||||
const isPolymorphic = (attribute) =>
|
||||
['morphOne', 'morphMany', 'morphToOne', 'morphToMany'].includes(attribute.relation);
|
||||
const isOneToAny = (attribute) => ['oneToOne', 'oneToMany'].includes(attribute.relation);
|
||||
const isManyToAny = (attribute) => ['manyToMany', 'manyToOne'].includes(attribute.relation);
|
||||
const isAnyToOne = (attribute) => ['oneToOne', 'manyToOne'].includes(attribute.relation);
|
||||
@ -564,7 +566,7 @@ const hasInverseOrderColumn = (attribute) => isBidirectional(attribute) && isMan
|
||||
|
||||
module.exports = {
|
||||
createRelation,
|
||||
|
||||
isPolymorphic,
|
||||
isBidirectional,
|
||||
isOneToAny,
|
||||
isManyToAny,
|
||||
|
||||
@ -8,7 +8,7 @@ import { formatAxiosError } from './utils/formatAxiosError';
|
||||
* Hook that exports an error message formatting function.
|
||||
*
|
||||
* @export
|
||||
* @param {function=} - Error message prefix function (usually getTrad())
|
||||
* @param {function} - Error message prefix function (usually getTrad())
|
||||
* @return {{ formatAPIError }} - Object containing an formatting function
|
||||
*/
|
||||
|
||||
|
||||
@ -322,6 +322,95 @@ const deleteComponents = async (uid, entityToDelete, { loadComponents = true } =
|
||||
}
|
||||
};
|
||||
|
||||
const cloneComponents = async (uid, entityToClone, data) => {
|
||||
const { attributes = {} } = strapi.getModel(uid);
|
||||
|
||||
const componentBody = {};
|
||||
const componentData = await getComponents(uid, entityToClone);
|
||||
|
||||
for (const attributeName of Object.keys(attributes)) {
|
||||
const attribute = attributes[attributeName];
|
||||
|
||||
// If the attribute is not set or on the component to clone, skip it
|
||||
if (!has(attributeName, data) && !has(attributeName, componentData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attribute.type === 'component') {
|
||||
const { component: componentUID, repeatable = false } = attribute;
|
||||
|
||||
const componentValue = has(attributeName, data)
|
||||
? data[attributeName]
|
||||
: componentData[attributeName];
|
||||
|
||||
if (componentValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repeatable === true) {
|
||||
if (!Array.isArray(componentValue)) {
|
||||
throw new Error('Expected an array to create repeatable component');
|
||||
}
|
||||
|
||||
// MySQL/MariaDB can cause deadlocks here if concurrency higher than 1
|
||||
const components = await mapAsync(
|
||||
componentValue,
|
||||
(value) => cloneComponent(componentUID, value),
|
||||
{ concurrency: isDialectMySQL() ? 1 : Infinity }
|
||||
);
|
||||
|
||||
componentBody[attributeName] = components.filter(_.negate(_.isNil)).map(({ id }) => {
|
||||
return {
|
||||
id,
|
||||
__pivot: {
|
||||
field: attributeName,
|
||||
component_type: componentUID,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const component = await cloneComponent(componentUID, componentValue);
|
||||
componentBody[attributeName] = component && {
|
||||
id: component.id,
|
||||
__pivot: {
|
||||
field: attributeName,
|
||||
component_type: componentUID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attribute.type === 'dynamiczone') {
|
||||
const dynamiczoneValues = has(attributeName, data)
|
||||
? data[attributeName]
|
||||
: componentData[attributeName];
|
||||
|
||||
if (!Array.isArray(dynamiczoneValues)) {
|
||||
throw new Error('Expected an array to create repeatable component');
|
||||
}
|
||||
// MySQL/MariaDB can cause deadlocks here if concurrency higher than 1
|
||||
componentBody[attributeName] = await mapAsync(
|
||||
dynamiczoneValues,
|
||||
async (value) => {
|
||||
const { id } = await cloneComponent(value.__component, value);
|
||||
return {
|
||||
id,
|
||||
__component: value.__component,
|
||||
__pivot: {
|
||||
field: attributeName,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ concurrency: isDialectMySQL() ? 1 : Infinity }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return componentBody;
|
||||
};
|
||||
/** *************************
|
||||
Component queries
|
||||
************************** */
|
||||
@ -377,6 +466,26 @@ const deleteComponent = async (uid, componentToDelete) => {
|
||||
await strapi.query(uid).delete({ where: { id: componentToDelete.id } });
|
||||
};
|
||||
|
||||
const cloneComponent = async (uid, data) => {
|
||||
const model = strapi.getModel(uid);
|
||||
|
||||
if (!has('id', data)) {
|
||||
return createComponent(uid, data);
|
||||
}
|
||||
|
||||
const componentData = await cloneComponents(uid, { id: data.id }, data);
|
||||
const transform = pipe(
|
||||
// Make sure we don't save the component with a pre-defined ID
|
||||
omit('id'),
|
||||
// Remove the component data from the original data object ...
|
||||
(payload) => omitComponentData(model, payload),
|
||||
// ... and assign the newly created component instead
|
||||
assign(componentData)
|
||||
);
|
||||
|
||||
return strapi.query(uid).clone(data.id, { data: transform(data) });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
omitComponentData,
|
||||
getComponents,
|
||||
@ -384,4 +493,5 @@ module.exports = {
|
||||
updateComponents,
|
||||
deleteComponents,
|
||||
deleteComponent,
|
||||
cloneComponents,
|
||||
};
|
||||
|
||||
@ -84,6 +84,11 @@ export interface EntityService {
|
||||
entityId: ID,
|
||||
params: Params<T>
|
||||
): Promise<any>;
|
||||
clone<K extends keyof AllTypes, T extends AllTypes[K]>(
|
||||
uid: K,
|
||||
cloneId: ID,
|
||||
params: Params<T>
|
||||
): Promise<any>;
|
||||
}
|
||||
|
||||
export default function (opts: {
|
||||
|
||||
@ -16,6 +16,7 @@ const {
|
||||
createComponents,
|
||||
updateComponents,
|
||||
deleteComponents,
|
||||
cloneComponents,
|
||||
} = require('./components');
|
||||
const { pickSelectionParams } = require('./params');
|
||||
const { applyTransforms } = require('./attributes');
|
||||
@ -267,6 +268,55 @@ const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator })
|
||||
return entityToDelete;
|
||||
},
|
||||
|
||||
async clone(uid, cloneId, opts) {
|
||||
const wrappedParams = await this.wrapParams(opts, { uid, action: 'clone' });
|
||||
const { data, files } = wrappedParams;
|
||||
|
||||
const model = strapi.getModel(uid);
|
||||
|
||||
const entityToClone = await db.query(uid).findOne({ where: { id: cloneId } });
|
||||
|
||||
if (!entityToClone) {
|
||||
return null;
|
||||
}
|
||||
const isDraft = contentTypesUtils.isDraft(entityToClone, model);
|
||||
|
||||
const validData = await entityValidator.validateEntityUpdate(
|
||||
model,
|
||||
data,
|
||||
{
|
||||
isDraft,
|
||||
},
|
||||
entityToClone
|
||||
);
|
||||
const query = transformParamsToQuery(uid, pickSelectionParams(wrappedParams));
|
||||
|
||||
// TODO: wrap into transaction
|
||||
const componentData = await cloneComponents(uid, entityToClone, validData);
|
||||
|
||||
const entityData = creationPipeline(
|
||||
Object.assign(omitComponentData(model, validData), componentData),
|
||||
{
|
||||
contentType: model,
|
||||
}
|
||||
);
|
||||
|
||||
let entity = await db.query(uid).clone(cloneId, {
|
||||
...query,
|
||||
data: entityData,
|
||||
});
|
||||
|
||||
// TODO: upload the files then set the links in the entity like with compo to avoid making too many queries
|
||||
if (files && Object.keys(files).length > 0) {
|
||||
await this.uploadFiles(uid, Object.assign(entityData, entity), files);
|
||||
entity = await this.findOne(uid, entity.id, wrappedParams);
|
||||
}
|
||||
|
||||
const { ENTRY_CREATE } = ALLOWED_WEBHOOK_EVENTS;
|
||||
await this.emitEvent(uid, ENTRY_CREATE, entity);
|
||||
|
||||
return entity;
|
||||
},
|
||||
// FIXME: used only for the CM to be removed
|
||||
async deleteMany(uid, opts) {
|
||||
const wrappedParams = await this.wrapParams(opts, { uid, action: 'delete' });
|
||||
|
||||
@ -149,7 +149,7 @@ describe('UploadAssetDialog', () => {
|
||||
});
|
||||
|
||||
it('snapshots the component with 4 URLs: 3 valid and one in failure', async () => {
|
||||
const { debug } = render();
|
||||
render();
|
||||
fireEvent.click(screen.getByText('From url'));
|
||||
|
||||
const urls = [
|
||||
@ -201,8 +201,6 @@ describe('UploadAssetDialog', () => {
|
||||
},
|
||||
];
|
||||
|
||||
debug(undefined, 100000);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user