Merge branch 'main' into feature/review-workflow-multiple-merge-main

This commit is contained in:
Gustav Hansen 2023-06-22 12:17:05 +02:00
commit 4410fb8f97
58 changed files with 1710 additions and 488 deletions

File diff suppressed because one or more lines are too long

View File

@ -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 = {

View File

@ -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 () => {

View File

@ -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 = {

View File

@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [];

View File

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

View File

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

View File

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

View File

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

View File

@ -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)`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1 @@
// export { default as getAllAllowedHeaders } from './getAllAllowedHeaders';
// export { default as getFirstSortableHeader } from './getFirstSortableHeader';
export { default as buildQueryString } from './buildQueryString';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
*/

View File

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

View File

@ -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: {

View File

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

View File

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