mirror of
				https://github.com/strapi/strapi.git
				synced 2025-10-31 01:47:13 +00:00 
			
		
		
		
	Merge branch 'develop' into fix/relations-read-rbac
This commit is contained in:
		
						commit
						ec04445427
					
				| @ -59,6 +59,10 @@ import { getDisplayName } from '../../utils/users'; | |||||||
| import { getData, getDataSucceeded } from '../ListViewLayoutManager'; | import { getData, getDataSucceeded } from '../ListViewLayoutManager'; | ||||||
| 
 | 
 | ||||||
| import { AdminUsersFilter } from './components/AdminUsersFilter'; | import { AdminUsersFilter } from './components/AdminUsersFilter'; | ||||||
|  | import { | ||||||
|  |   AutoCloneFailureModal, | ||||||
|  |   type ProhibitedCloningField, | ||||||
|  | } from './components/AutoCloneFailureModal'; | ||||||
| import { BulkActionButtons } from './components/BulkActions/Buttons'; | import { BulkActionButtons } from './components/BulkActions/Buttons'; | ||||||
| import { Filter } from './components/Filter'; | import { Filter } from './components/Filter'; | ||||||
| import { Table } from './components/Table'; | import { Table } from './components/Table'; | ||||||
| @ -597,6 +601,11 @@ const ListViewPage = ({ | |||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   const [clonedEntryId, setClonedEntryId] = React.useState<Entity.ID | null>(null); | ||||||
|  |   const [prohibitedCloningFields, setProhibitedCloningFields] = React.useState< | ||||||
|  |     ProhibitedCloningField[] | ||||||
|  |   >([]); | ||||||
|  | 
 | ||||||
|   const handleCloneClick = |   const handleCloneClick = | ||||||
|     (id: Contracts.CollectionTypes.AutoClone.Params['sourceId']) => async () => { |     (id: Contracts.CollectionTypes.AutoClone.Params['sourceId']) => async () => { | ||||||
|       try { |       try { | ||||||
| @ -613,11 +622,9 @@ const ListViewPage = ({ | |||||||
|         } |         } | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         if (err instanceof AxiosError) { |         if (err instanceof AxiosError) { | ||||||
|           push({ |           const { prohibitedFields } = err.response?.data.error.details; | ||||||
|             pathname: `${pathname}/create/clone/${id}`, |           setClonedEntryId(id); | ||||||
|             state: { from: pathname, error: formatAPIError(err) }, |           setProhibitedCloningFields(prohibitedFields); | ||||||
|             search: pluginsQueryParams, |  | ||||||
|           }); |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
| @ -746,6 +753,12 @@ const ListViewPage = ({ | |||||||
|                     ) : null |                     ) : null | ||||||
|                   } |                   } | ||||||
|                 /> |                 /> | ||||||
|  |                 <AutoCloneFailureModal | ||||||
|  |                   entryId={clonedEntryId} | ||||||
|  |                   onClose={() => setClonedEntryId(null)} | ||||||
|  |                   prohibitedFields={prohibitedCloningFields} | ||||||
|  |                   pluginQueryParams={pluginsQueryParams} | ||||||
|  |                 /> | ||||||
|                 {/* Content */} |                 {/* Content */} | ||||||
|                 <Table.Root |                 <Table.Root | ||||||
|                   onConfirmDelete={handleConfirmDeleteData} |                   onConfirmDelete={handleConfirmDeleteData} | ||||||
|  | |||||||
| @ -0,0 +1,147 @@ | |||||||
|  | import * as React from 'react'; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |   Box, | ||||||
|  |   Button, | ||||||
|  |   Flex, | ||||||
|  |   Icon, | ||||||
|  |   ModalBody, | ||||||
|  |   ModalFooter, | ||||||
|  |   ModalHeader, | ||||||
|  |   ModalLayout, | ||||||
|  |   Typography, | ||||||
|  | } from '@strapi/design-system'; | ||||||
|  | import { LinkButton } from '@strapi/design-system/v2'; | ||||||
|  | import { ChevronRight } from '@strapi/icons'; | ||||||
|  | import { useIntl } from 'react-intl'; | ||||||
|  | import { useLocation, NavLink } from 'react-router-dom'; | ||||||
|  | 
 | ||||||
|  | import { getTranslation } from '../../../utils/translations'; | ||||||
|  | 
 | ||||||
|  | import type { Entity } from '@strapi/types'; | ||||||
|  | 
 | ||||||
|  | type Reason = 'relation' | 'unique'; | ||||||
|  | type ProhibitedCloningField = [string[], Reason]; | ||||||
|  | 
 | ||||||
|  | interface AutoCloneFailureModalProps { | ||||||
|  |   onClose: () => 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 ( | ||||||
|  |     <ModalLayout onClose={onClose} labelledBy="title"> | ||||||
|  |       <ModalHeader> | ||||||
|  |         <Typography variant="omega" fontWeight="bold" as="h2" id="title"> | ||||||
|  |           {formatMessage({ | ||||||
|  |             id: getTranslation('containers.ListPage.autoCloneModal.header'), | ||||||
|  |             defaultMessage: 'Duplicate', | ||||||
|  |           })} | ||||||
|  |         </Typography> | ||||||
|  |       </ModalHeader> | ||||||
|  |       <ModalBody> | ||||||
|  |         <Typography variant="beta"> | ||||||
|  |           {formatMessage({ | ||||||
|  |             id: getTranslation('containers.ListPage.autoCloneModal.title'), | ||||||
|  |             defaultMessage: "This entry can't be duplicated directly.", | ||||||
|  |           })} | ||||||
|  |         </Typography> | ||||||
|  |         <Box marginTop={2}> | ||||||
|  |           <Typography textColor="neutral600"> | ||||||
|  |             {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.", | ||||||
|  |             })} | ||||||
|  |           </Typography> | ||||||
|  |         </Box> | ||||||
|  |         <Flex marginTop={6} gap={2} direction="column" alignItems="stretch"> | ||||||
|  |           {prohibitedFields.map(([fieldPath, reason]) => ( | ||||||
|  |             <Flex | ||||||
|  |               direction="column" | ||||||
|  |               gap={2} | ||||||
|  |               alignItems="flex-start" | ||||||
|  |               borderColor="neutral200" | ||||||
|  |               hasRadius | ||||||
|  |               padding={6} | ||||||
|  |               key={fieldPath.join()} | ||||||
|  |             > | ||||||
|  |               <Flex direction="row" as="ol"> | ||||||
|  |                 {fieldPath.map((pathSegment, index) => ( | ||||||
|  |                   <Typography fontWeight="semiBold" as="li" key={index}> | ||||||
|  |                     {pathSegment} | ||||||
|  |                     {index !== fieldPath.length - 1 && ( | ||||||
|  |                       <Icon | ||||||
|  |                         as={ChevronRight} | ||||||
|  |                         color="neutral500" | ||||||
|  |                         height={2} | ||||||
|  |                         width={2} | ||||||
|  |                         marginLeft={2} | ||||||
|  |                         marginRight={2} | ||||||
|  |                       /> | ||||||
|  |                     )} | ||||||
|  |                   </Typography> | ||||||
|  |                 ))} | ||||||
|  |               </Flex> | ||||||
|  |               <Typography as="p" textColor="neutral600"> | ||||||
|  |                 {formatMessage({ | ||||||
|  |                   id: getTranslation(`containers.ListPage.autoCloneModal.error.${reason}`), | ||||||
|  |                   defaultMessage: getDefaultErrorMessage(reason), | ||||||
|  |                 })} | ||||||
|  |               </Typography> | ||||||
|  |             </Flex> | ||||||
|  |           ))} | ||||||
|  |         </Flex> | ||||||
|  |       </ModalBody> | ||||||
|  |       <ModalFooter | ||||||
|  |         startActions={ | ||||||
|  |           <Button onClick={onClose} variant="tertiary"> | ||||||
|  |             {formatMessage({ | ||||||
|  |               id: 'cancel', | ||||||
|  |               defaultMessage: 'Cancel', | ||||||
|  |             })} | ||||||
|  |           </Button> | ||||||
|  |         } | ||||||
|  |         endActions={ | ||||||
|  |           // @ts-expect-error - types are not inferred correctly through the as prop.
 | ||||||
|  |           <LinkButton as={NavLink} to={editPath}> | ||||||
|  |             {formatMessage({ | ||||||
|  |               id: getTranslation('containers.ListPage.autoCloneModal.create'), | ||||||
|  |               defaultMessage: 'Create', | ||||||
|  |             })} | ||||||
|  |           </LinkButton> | ||||||
|  |         } | ||||||
|  |       /> | ||||||
|  |     </ModalLayout> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export { AutoCloneFailureModal }; | ||||||
|  | export type { ProhibitedCloningField }; | ||||||
| @ -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<typeof AutoCloneFailureModal>) => | ||||||
|  |   renderRTL(<AutoCloneFailureModal {...props} />, { | ||||||
|  |     renderOptions: { | ||||||
|  |       wrapper({ children }) { | ||||||
|  |         return ( | ||||||
|  |           <> | ||||||
|  |             {children} | ||||||
|  |             <Route | ||||||
|  |               path="*" | ||||||
|  |               render={({ location }) => { | ||||||
|  |                 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'); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -779,6 +779,12 @@ | |||||||
|   "content-manager.containers.ListPage.selectedEntriesModal.title": "Publish entries", |   "content-manager.containers.ListPage.selectedEntriesModal.title": "Publish entries", | ||||||
|   "content-manager.containers.ListPage.selectedEntriesModal.selectedCount": "<b>{alreadyPublishedCount}</b> {alreadyPublishedCount, plural, =0 {entries} one {entry} other {entries}} already published. <b>{readyToPublishCount}</b> {readyToPublishCount, plural, =0 {entries} one {entry} other {entries}} ready to publish. <b>{withErrorsCount}</b> {withErrorsCount, plural, =0 {entries} one {entry} other {entries}} waiting for action.", |   "content-manager.containers.ListPage.selectedEntriesModal.selectedCount": "<b>{alreadyPublishedCount}</b> {alreadyPublishedCount, plural, =0 {entries} one {entry} other {entries}} already published. <b>{readyToPublishCount}</b> {readyToPublishCount, plural, =0 {entries} one {entry} other {entries}} ready to publish. <b>{withErrorsCount}</b> {withErrorsCount, plural, =0 {entries} one {entry} other {entries}} waiting for action.", | ||||||
|   "content-manager.containers.ListPage.selectedEntriesModal.publishedCount": "<b>{publishedCount}</b> {publishedCount, plural, =0 {entries} one {entry} other {entries}} published. <b>{withErrorsCount}</b> {withErrorsCount, plural, =0 {entries} one {entry} other {entries}} waiting for action.", |   "content-manager.containers.ListPage.selectedEntriesModal.publishedCount": "<b>{publishedCount}</b> {publishedCount, plural, =0 {entries} one {entry} other {entries}} published. <b>{withErrorsCount}</b> {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.ListSettingsView.modal-form.edit-label": "Edit {fieldName}", | ||||||
|   "content-manager.containers.SettingPage.add.field": "Insert another field", |   "content-manager.containers.SettingPage.add.field": "Insert another field", | ||||||
|   "content-manager.containers.SettingPage.add.relational-field": "Insert another related field", |   "content-manager.containers.SettingPage.add.relational-field": "Insert another related field", | ||||||
|  | |||||||
| @ -1,9 +1,7 @@ | |||||||
| import { setCreatorFields, pipeAsync, errors } from '@strapi/utils'; | import { setCreatorFields, pipeAsync } from '@strapi/utils'; | ||||||
| import { getService } from '../utils'; | import { getService } from '../utils'; | ||||||
| import { validateBulkActionInput } from './validation'; | import { validateBulkActionInput } from './validation'; | ||||||
| import { hasProhibitedCloningFields, excludeNotCreatableFields } from './utils/clone'; | import { getProhibitedCloningFields, excludeNotCreatableFields } from './utils/clone'; | ||||||
| 
 |  | ||||||
| const { ApplicationError } = errors; |  | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async find(ctx: any) { |   async find(ctx: any) { | ||||||
| @ -192,11 +190,16 @@ export default { | |||||||
|   async autoClone(ctx: any) { |   async autoClone(ctx: any) { | ||||||
|     const { model } = ctx.params; |     const { model } = ctx.params; | ||||||
| 
 | 
 | ||||||
|     // Trying to automatically clone the entity and model has unique or relational fields
 |     // Check if the model has fields that prevent auto cloning
 | ||||||
|     if (hasProhibitedCloningFields(model)) { |     const prohibitedFields = getProhibitedCloningFields(model); | ||||||
|       throw new ApplicationError( | 
 | ||||||
|  |     if (prohibitedFields.length > 0) { | ||||||
|  |       return ctx.badRequest( | ||||||
|         'Entity could not be cloned as it has unique and/or relational fields. ' + |         '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, | ||||||
|  |         } | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,9 +1,12 @@ | |||||||
| import { hasProhibitedCloningFields } from '../clone'; | import { getProhibitedCloningFields } from '../clone'; | ||||||
| 
 | 
 | ||||||
| describe('Populate', () => { | describe('Populate', () => { | ||||||
|   const fakeModels = { |   const fakeModels = { | ||||||
|     simple: { |     simple: { | ||||||
|       modelName: 'Fake simple model', |       modelName: 'Fake simple model', | ||||||
|  |       info: { | ||||||
|  |         displayName: 'Simple', | ||||||
|  |       }, | ||||||
|       attributes: { |       attributes: { | ||||||
|         text: { |         text: { | ||||||
|           type: 'string', |           type: 'string', | ||||||
| @ -21,6 +24,9 @@ describe('Populate', () => { | |||||||
|     }, |     }, | ||||||
|     component: { |     component: { | ||||||
|       modelName: 'Fake component model', |       modelName: 'Fake component model', | ||||||
|  |       info: { | ||||||
|  |         displayName: 'Fake component', | ||||||
|  |       }, | ||||||
|       attributes: { |       attributes: { | ||||||
|         componentAttrName: { |         componentAttrName: { | ||||||
|           type: 'component', |           type: 'component', | ||||||
| @ -30,6 +36,9 @@ describe('Populate', () => { | |||||||
|     }, |     }, | ||||||
|     componentUnique: { |     componentUnique: { | ||||||
|       modelName: 'Fake component model', |       modelName: 'Fake component model', | ||||||
|  |       info: { | ||||||
|  |         displayName: 'Unique Component', | ||||||
|  |       }, | ||||||
|       attributes: { |       attributes: { | ||||||
|         componentAttrName: { |         componentAttrName: { | ||||||
|           type: 'component', |           type: 'component', | ||||||
| @ -55,12 +64,51 @@ describe('Populate', () => { | |||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|     relation: { |     relations: { | ||||||
|       modelName: 'Fake relation oneToMany model', |       modelName: 'Fake relation oneToMany model', | ||||||
|       attributes: { |       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', |           type: 'relation', | ||||||
|           relation: 'oneToMany', |           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', () => { |     test('model without unique fields', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('simple'); |       const prohibitedFields = getProhibitedCloningFields('simple'); | ||||||
|       expect(hasProhibitedFields).toEqual(false); |       expect(prohibitedFields).toHaveLength(0); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('model with unique fields', () => { |     test('model with unique fields', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('simpleUnique'); |       const prohibitedFields = getProhibitedCloningFields('simpleUnique'); | ||||||
|       expect(hasProhibitedFields).toEqual(true); |       expect(prohibitedFields).toEqual([[['text'], 'unique']]); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('model with component', () => { |     test('model with component', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('component'); |       const prohibitedFields = getProhibitedCloningFields('component'); | ||||||
|       expect(hasProhibitedFields).toEqual(false); |       expect(prohibitedFields).toHaveLength(0); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('model with component & unique fields', () => { |     test('model with component & unique fields', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('componentUnique'); |       const prohibitedFields = getProhibitedCloningFields('componentUnique'); | ||||||
|       expect(hasProhibitedFields).toEqual(true); |       expect(prohibitedFields).toEqual([[['componentAttrName', 'text'], 'unique']]); | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     test('model with component & unique fields', () => { |  | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('componentUnique'); |  | ||||||
|       expect(hasProhibitedFields).toEqual(true); |  | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('model with dynamic zone', () => { |     test('model with dynamic zone', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('dynZone'); |       const prohibitedFields = getProhibitedCloningFields('dynZone'); | ||||||
|       expect(hasProhibitedFields).toEqual(false); |       expect(prohibitedFields).toHaveLength(0); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('model with dynamic zone', () => { |     test('model with unique component in dynamic zone', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('dynZoneUnique'); |       const prohibitedFields = getProhibitedCloningFields('dynZoneUnique'); | ||||||
|       expect(hasProhibitedFields).toEqual(true); |       expect(prohibitedFields).toEqual([ | ||||||
|  |         [['dynZoneAttrName', 'Unique Component', 'componentAttrName', 'text'], 'unique'], | ||||||
|  |       ]); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('model with relation', () => { |     test('model with relations', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('relation'); |       const prohibitedFields = getProhibitedCloningFields('relations'); | ||||||
|       expect(hasProhibitedFields).toEqual(true); |       expect(prohibitedFields).toEqual([ | ||||||
|  |         [['one_to_one'], 'relation'], | ||||||
|  |         [['one_to_many'], 'relation'], | ||||||
|  |       ]); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('model with media', () => { |     test('model with media', () => { | ||||||
|       const hasProhibitedFields = hasProhibitedCloningFields('media'); |       const prohibitedFields = getProhibitedCloningFields('media'); | ||||||
|       expect(hasProhibitedFields).toEqual(false); |       expect(prohibitedFields).toHaveLength(0); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -3,36 +3,75 @@ import strapiUtils from '@strapi/utils'; | |||||||
| 
 | 
 | ||||||
| const { isVisibleAttribute } = strapiUtils.contentTypes; | 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.
 |   // we don't care about createdBy, updatedBy, localizations etc.
 | ||||||
|   if (!isVisibleAttribute(model, attributeName)) { |   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); |   const model = strapi.getModel(uid); | ||||||
| 
 | 
 | ||||||
|   return Object.keys(model.attributes).some((attributeName: any) => { |   const prohibitedFields = Object.keys(model.attributes).reduce<ProhibitedCloningField[]>( | ||||||
|     const attribute: any = model.attributes[attributeName]; |     (acc, attributeName) => { | ||||||
|  |       const attribute: any = model.attributes[attributeName]; | ||||||
|  |       const attributePath = [...pathPrefix, attributeName]; | ||||||
| 
 | 
 | ||||||
|     switch (attribute.type) { |       switch (attribute.type) { | ||||||
|       case 'relation': |         case 'relation': | ||||||
|         return isProhibitedRelation(model, attributeName); |           return [...acc, ...checkRelation(model, attributeName, pathPrefix)]; | ||||||
|       case 'component': |         case 'component': | ||||||
|         return hasProhibitedCloningFields(attribute.component); |           return [...acc, ...getProhibitedCloningFields(attribute.component, attributePath)]; | ||||||
|       case 'dynamiczone': |         case 'dynamiczone': | ||||||
|         return (attribute.components || []).some((componentUID: any) => |           return [ | ||||||
|           hasProhibitedCloningFields(componentUID) |             ...acc, | ||||||
|         ); |             ...(attribute.components || []).flatMap((componentUID: any) => | ||||||
|       case 'uid': |               getProhibitedCloningFields(componentUID, [ | ||||||
|         return true; |                 ...attributePath, | ||||||
|       default: |                 strapi.getModel(componentUID).info.displayName, | ||||||
|         return attribute?.unique ?? false; |               ]) | ||||||
|     } |             ), | ||||||
|   }); |           ]; | ||||||
|  |         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); |     }, body); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
| export { hasProhibitedCloningFields, excludeNotCreatableFields }; | export { getProhibitedCloningFields, excludeNotCreatableFields }; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Bassel Kanso
						Bassel Kanso