diff --git a/packages/core/admin/admin/src/content-manager/pages/ListView/ListViewPage.tsx b/packages/core/admin/admin/src/content-manager/pages/ListView/ListViewPage.tsx index eba9f55a51..ceb94a1b1d 100644 --- a/packages/core/admin/admin/src/content-manager/pages/ListView/ListViewPage.tsx +++ b/packages/core/admin/admin/src/content-manager/pages/ListView/ListViewPage.tsx @@ -59,6 +59,10 @@ import { getDisplayName } from '../../utils/users'; import { getData, getDataSucceeded } from '../ListViewLayoutManager'; import { AdminUsersFilter } from './components/AdminUsersFilter'; +import { + AutoCloneFailureModal, + type ProhibitedCloningField, +} from './components/AutoCloneFailureModal'; import { BulkActionButtons } from './components/BulkActions/Buttons'; import { Filter } from './components/Filter'; import { Table } from './components/Table'; @@ -597,6 +601,11 @@ const ListViewPage = ({ }); }; + const [clonedEntryId, setClonedEntryId] = React.useState(null); + const [prohibitedCloningFields, setProhibitedCloningFields] = React.useState< + ProhibitedCloningField[] + >([]); + const handleCloneClick = (id: Contracts.CollectionTypes.AutoClone.Params['sourceId']) => async () => { try { @@ -613,11 +622,9 @@ const ListViewPage = ({ } } catch (err) { if (err instanceof AxiosError) { - push({ - pathname: `${pathname}/create/clone/${id}`, - state: { from: pathname, error: formatAPIError(err) }, - search: pluginsQueryParams, - }); + const { prohibitedFields } = err.response?.data.error.details; + setClonedEntryId(id); + setProhibitedCloningFields(prohibitedFields); } } }; @@ -746,6 +753,12 @@ const ListViewPage = ({ ) : null } /> + setClonedEntryId(null)} + prohibitedFields={prohibitedCloningFields} + pluginQueryParams={pluginsQueryParams} + /> {/* Content */} void; + entryId: Entity.ID | null; + prohibitedFields: ProhibitedCloningField[]; + pluginQueryParams: string; +} + +const AutoCloneFailureModal = ({ + onClose, + entryId, + prohibitedFields, + pluginQueryParams, +}: AutoCloneFailureModalProps) => { + const { formatMessage } = useIntl(); + const { pathname } = useLocation(); + + if (!entryId) { + return null; + } + + const editPath = `${pathname}/create/clone/${entryId}?${pluginQueryParams}`; + + const getDefaultErrorMessage = (reason: Reason) => { + switch (reason) { + case 'relation': + return 'Duplicating the relation could remove it from the original entry.'; + case 'unique': + return 'Identical values in a unique field are not allowed'; + default: + return reason; + } + }; + + return ( + + + + {formatMessage({ + id: getTranslation('containers.ListPage.autoCloneModal.header'), + defaultMessage: 'Duplicate', + })} + + + + + {formatMessage({ + id: getTranslation('containers.ListPage.autoCloneModal.title'), + defaultMessage: "This entry can't be duplicated directly.", + })} + + + + {formatMessage({ + id: getTranslation('containers.ListPage.autoCloneModal.description'), + defaultMessage: + "A new entry will be created with the same content, but you'll have to change the following fields to save it.", + })} + + + + {prohibitedFields.map(([fieldPath, reason]) => ( + + + {fieldPath.map((pathSegment, index) => ( + + {pathSegment} + {index !== fieldPath.length - 1 && ( + + )} + + ))} + + + {formatMessage({ + id: getTranslation(`containers.ListPage.autoCloneModal.error.${reason}`), + defaultMessage: getDefaultErrorMessage(reason), + })} + + + ))} + + + + {formatMessage({ + id: 'cancel', + defaultMessage: 'Cancel', + })} + + } + endActions={ + // @ts-expect-error - types are not inferred correctly through the as prop. + + {formatMessage({ + id: getTranslation('containers.ListPage.autoCloneModal.create'), + defaultMessage: 'Create', + })} + + } + /> + + ); +}; + +export { AutoCloneFailureModal }; +export type { ProhibitedCloningField }; diff --git a/packages/core/admin/admin/src/content-manager/pages/ListView/components/tests/AutoCloneFailureModal.test.tsx b/packages/core/admin/admin/src/content-manager/pages/ListView/components/tests/AutoCloneFailureModal.test.tsx new file mode 100644 index 0000000000..854310d075 --- /dev/null +++ b/packages/core/admin/admin/src/content-manager/pages/ListView/components/tests/AutoCloneFailureModal.test.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; + +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render as renderRTL, screen } from '@tests/utils'; +import { Route } from 'react-router-dom'; + +import { AutoCloneFailureModal } from '../AutoCloneFailureModal'; + +import type { Location } from 'history'; + +const user = userEvent.setup(); + +let testLocation: Location = null!; + +const render = (props: React.ComponentProps) => + renderRTL(, { + renderOptions: { + wrapper({ children }) { + return ( + <> + {children} + { + testLocation = location; + + return null; + }} + /> + + ); + }, + }, + initialEntries: ['/content-manager/collection-types/api::model.model?plugins[i18n][locale]=en'], + }); + +describe('AutoCloneFailureModal', () => { + it('renders nothing if there is no entryId', () => { + render({ entryId: null, onClose: jest.fn(), prohibitedFields: [], pluginQueryParams: '' }); + + expect(screen.queryByText(/duplicate/i)).not.toBeInTheDocument(); + }); + + it('toggles the modal', async () => { + const onClose = jest.fn(); + render({ entryId: 1, onClose, prohibitedFields: [], pluginQueryParams: '' }); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows the fields that prevent duplication', async () => { + render({ + entryId: 1, + onClose: jest.fn(), + prohibitedFields: [ + [['dynZoneAttrName', 'Unique Component', 'componentAttrName', 'text'], 'unique'], + [['oneToOneRelation'], 'relation'], + ], + pluginQueryParams: 'plugins[i18n][locale]=en', + }); + + const lists = screen.getAllByRole('list'); + expect(lists).toHaveLength(2); + screen.getByText(/identical values in a unique field are not allowed/i); + screen.getByText(/duplicating the relation could remove it/i); + + const uniqueSegments = within(lists[0]).getAllByRole('listitem'); + expect(uniqueSegments).toHaveLength(4); + within(uniqueSegments[1]).getByText('Unique Component'); + within(uniqueSegments[3]).getByText('text'); + + const relationSegments = within(lists[1]).getAllByRole('listitem'); + expect(relationSegments).toHaveLength(1); + within(relationSegments[0]).getByText('oneToOneRelation'); + + // Links to the edit cloned entry page + await user.click(screen.getByRole('link', { name: /create/i })); + expect(testLocation.pathname).toBe( + '/content-manager/collection-types/api::model.model/create/clone/1' + ); + expect(testLocation.search).toBe('?plugins[i18n][locale]=en'); + }); +}); diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index b1d434583b..8d2887efd9 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -779,6 +779,12 @@ "content-manager.containers.ListPage.selectedEntriesModal.title": "Publish entries", "content-manager.containers.ListPage.selectedEntriesModal.selectedCount": "{alreadyPublishedCount} {alreadyPublishedCount, plural, =0 {entries} one {entry} other {entries}} already published. {readyToPublishCount} {readyToPublishCount, plural, =0 {entries} one {entry} other {entries}} ready to publish. {withErrorsCount} {withErrorsCount, plural, =0 {entries} one {entry} other {entries}} waiting for action.", "content-manager.containers.ListPage.selectedEntriesModal.publishedCount": "{publishedCount} {publishedCount, plural, =0 {entries} one {entry} other {entries}} published. {withErrorsCount} {withErrorsCount, plural, =0 {entries} one {entry} other {entries}} waiting for action.", + "content-manager.containers.ListPage.autoCloneModal.header": "Duplicate", + "content-manager.containers.ListPage.autoCloneModal.title": "This entry can't be duplicated directly.", + "content-manager.containers.ListPage.autoCloneModal.description": "A new entry will be created with the same content, but you'll have to change the following fields to save it.", + "content-manager.containers.ListPage.autoCloneModal.create": "Create", + "content-manager.containers.ListPage.autoCloneModal.error.unique": "Identical values in a unique field are not allowed.", + "content-manager.containers.ListPage.autoCloneModal.error.relation": "Duplicating the relation could remove it from the original entry.", "content-manager.containers.ListSettingsView.modal-form.edit-label": "Edit {fieldName}", "content-manager.containers.SettingPage.add.field": "Insert another field", "content-manager.containers.SettingPage.add.relational-field": "Insert another related field", diff --git a/packages/core/content-manager/server/src/controllers/collection-types.ts b/packages/core/content-manager/server/src/controllers/collection-types.ts index 4dd6f54f48..1003601812 100644 --- a/packages/core/content-manager/server/src/controllers/collection-types.ts +++ b/packages/core/content-manager/server/src/controllers/collection-types.ts @@ -1,9 +1,7 @@ -import { setCreatorFields, pipeAsync, errors } from '@strapi/utils'; +import { setCreatorFields, pipeAsync } from '@strapi/utils'; import { getService } from '../utils'; import { validateBulkActionInput } from './validation'; -import { hasProhibitedCloningFields, excludeNotCreatableFields } from './utils/clone'; - -const { ApplicationError } = errors; +import { getProhibitedCloningFields, excludeNotCreatableFields } from './utils/clone'; export default { async find(ctx: any) { @@ -192,11 +190,16 @@ export default { async autoClone(ctx: any) { const { model } = ctx.params; - // Trying to automatically clone the entity and model has unique or relational fields - if (hasProhibitedCloningFields(model)) { - throw new ApplicationError( + // Check if the model has fields that prevent auto cloning + const prohibitedFields = getProhibitedCloningFields(model); + + if (prohibitedFields.length > 0) { + return ctx.badRequest( 'Entity could not be cloned as it has unique and/or relational fields. ' + - 'Please edit those fields manually and save to complete the cloning.' + 'Please edit those fields manually and save to complete the cloning.', + { + prohibitedFields, + } ); } diff --git a/packages/core/content-manager/server/src/controllers/utils/__tests__/clone.test.ts b/packages/core/content-manager/server/src/controllers/utils/__tests__/clone.test.ts index f20d952574..637799674b 100644 --- a/packages/core/content-manager/server/src/controllers/utils/__tests__/clone.test.ts +++ b/packages/core/content-manager/server/src/controllers/utils/__tests__/clone.test.ts @@ -1,9 +1,12 @@ -import { hasProhibitedCloningFields } from '../clone'; +import { getProhibitedCloningFields } from '../clone'; describe('Populate', () => { const fakeModels = { simple: { modelName: 'Fake simple model', + info: { + displayName: 'Simple', + }, attributes: { text: { type: 'string', @@ -21,6 +24,9 @@ describe('Populate', () => { }, component: { modelName: 'Fake component model', + info: { + displayName: 'Fake component', + }, attributes: { componentAttrName: { type: 'component', @@ -30,6 +36,9 @@ describe('Populate', () => { }, componentUnique: { modelName: 'Fake component model', + info: { + displayName: 'Unique Component', + }, attributes: { componentAttrName: { type: 'component', @@ -55,12 +64,51 @@ describe('Populate', () => { }, }, }, - relation: { + relations: { modelName: 'Fake relation oneToMany model', attributes: { - relationAttrName: { + one_way: { + type: 'relation', + relation: 'oneToOne', + target: 'simple', + }, + one_to_one: { + type: 'relation', + relation: 'oneToOne', + target: 'simple', + private: true, + inversedBy: 'one_to_one_kitchensink', + }, + one_to_many: { type: 'relation', relation: 'oneToMany', + target: 'simple', + mappedBy: 'many_to_one_kitchensink', + }, + many_to_one: { + type: 'relation', + relation: 'manyToOne', + target: 'simple', + inversedBy: 'one_to_many_kitchensinks', + }, + many_to_manys: { + type: 'relation', + relation: 'manyToMany', + target: 'simple', + inversedBy: 'many_to_many_kitchensinks', + }, + many_way: { + type: 'relation', + relation: 'oneToMany', + target: 'simple', + }, + morph_to_one: { + type: 'relation', + relation: 'morphToOne', + }, + morph_to_many: { + type: 'relation', + relation: 'morphToMany', }, }, }, @@ -86,48 +134,48 @@ describe('Populate', () => { }); test('model without unique fields', () => { - const hasProhibitedFields = hasProhibitedCloningFields('simple'); - expect(hasProhibitedFields).toEqual(false); + const prohibitedFields = getProhibitedCloningFields('simple'); + expect(prohibitedFields).toHaveLength(0); }); test('model with unique fields', () => { - const hasProhibitedFields = hasProhibitedCloningFields('simpleUnique'); - expect(hasProhibitedFields).toEqual(true); + const prohibitedFields = getProhibitedCloningFields('simpleUnique'); + expect(prohibitedFields).toEqual([[['text'], 'unique']]); }); test('model with component', () => { - const hasProhibitedFields = hasProhibitedCloningFields('component'); - expect(hasProhibitedFields).toEqual(false); + const prohibitedFields = getProhibitedCloningFields('component'); + expect(prohibitedFields).toHaveLength(0); }); 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); + const prohibitedFields = getProhibitedCloningFields('componentUnique'); + expect(prohibitedFields).toEqual([[['componentAttrName', 'text'], 'unique']]); }); test('model with dynamic zone', () => { - const hasProhibitedFields = hasProhibitedCloningFields('dynZone'); - expect(hasProhibitedFields).toEqual(false); + const prohibitedFields = getProhibitedCloningFields('dynZone'); + expect(prohibitedFields).toHaveLength(0); }); - test('model with dynamic zone', () => { - const hasProhibitedFields = hasProhibitedCloningFields('dynZoneUnique'); - expect(hasProhibitedFields).toEqual(true); + test('model with unique component in dynamic zone', () => { + const prohibitedFields = getProhibitedCloningFields('dynZoneUnique'); + expect(prohibitedFields).toEqual([ + [['dynZoneAttrName', 'Unique Component', 'componentAttrName', 'text'], 'unique'], + ]); }); - test('model with relation', () => { - const hasProhibitedFields = hasProhibitedCloningFields('relation'); - expect(hasProhibitedFields).toEqual(true); + test('model with relations', () => { + const prohibitedFields = getProhibitedCloningFields('relations'); + expect(prohibitedFields).toEqual([ + [['one_to_one'], 'relation'], + [['one_to_many'], 'relation'], + ]); }); test('model with media', () => { - const hasProhibitedFields = hasProhibitedCloningFields('media'); - expect(hasProhibitedFields).toEqual(false); + const prohibitedFields = getProhibitedCloningFields('media'); + expect(prohibitedFields).toHaveLength(0); }); }); }); diff --git a/packages/core/content-manager/server/src/controllers/utils/clone.ts b/packages/core/content-manager/server/src/controllers/utils/clone.ts index d614c7b5fd..f87bd3fd74 100644 --- a/packages/core/content-manager/server/src/controllers/utils/clone.ts +++ b/packages/core/content-manager/server/src/controllers/utils/clone.ts @@ -3,36 +3,75 @@ import strapiUtils from '@strapi/utils'; const { isVisibleAttribute } = strapiUtils.contentTypes; -function isProhibitedRelation(model: any, attributeName: any) { +/** + * Use an array of strings to represent the path to a field, so we can show breadcrumbs in the admin + * We can't use special characters as delimiters, because the path includes display names + * for dynamic zone components, which can contain any character. + */ +type ProhibitedCloningField = [string[], 'unique' | 'relation']; + +function checkRelation(model: any, attributeName: any, path: string[]): ProhibitedCloningField[] { // we don't care about createdBy, updatedBy, localizations etc. if (!isVisibleAttribute(model, attributeName)) { - return false; + // Return empty array and not null so we can always spread the result + return []; } - return true; + /** + * Only one-to-many and one-to-one (when they're reversed, not one-way) are dangerous, + * because the other relations don't "steal" the relation from the entry we're cloning + */ + const { relation, inversedBy, mappedBy } = model.attributes[attributeName]; + + if ( + ['oneToOne', 'oneToMany'].includes(relation) && + [mappedBy, inversedBy].some((key) => key != null) + ) { + return [[[...path, attributeName], 'relation']]; + } + + return []; } -const hasProhibitedCloningFields = (uid: any): boolean => { +const getProhibitedCloningFields = ( + uid: any, + pathPrefix: string[] = [] +): ProhibitedCloningField[] => { const model = strapi.getModel(uid); - return Object.keys(model.attributes).some((attributeName: any) => { - const attribute: any = model.attributes[attributeName]; + const prohibitedFields = Object.keys(model.attributes).reduce( + (acc, attributeName) => { + const attribute: any = model.attributes[attributeName]; + const attributePath = [...pathPrefix, attributeName]; - switch (attribute.type) { - case 'relation': - return isProhibitedRelation(model, attributeName); - case 'component': - return hasProhibitedCloningFields(attribute.component); - case 'dynamiczone': - return (attribute.components || []).some((componentUID: any) => - hasProhibitedCloningFields(componentUID) - ); - case 'uid': - return true; - default: - return attribute?.unique ?? false; - } - }); + switch (attribute.type) { + case 'relation': + return [...acc, ...checkRelation(model, attributeName, pathPrefix)]; + case 'component': + return [...acc, ...getProhibitedCloningFields(attribute.component, attributePath)]; + case 'dynamiczone': + return [ + ...acc, + ...(attribute.components || []).flatMap((componentUID: any) => + getProhibitedCloningFields(componentUID, [ + ...attributePath, + strapi.getModel(componentUID).info.displayName, + ]) + ), + ]; + case 'uid': + return [...acc, [attributePath, 'unique']]; + default: + if (attribute?.unique) { + return [...acc, [attributePath, 'unique']]; + } + return acc; + } + }, + [] + ); + + return prohibitedFields; }; /** @@ -79,4 +118,4 @@ const excludeNotCreatableFields = }, body); }; -export { hasProhibitedCloningFields, excludeNotCreatableFields }; +export { getProhibitedCloningFields, excludeNotCreatableFields };