mirror of
https://github.com/strapi/strapi.git
synced 2025-09-07 07:41:08 +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