Merge branch 'develop' into fix/relations-read-rbac

This commit is contained in:
Bassel Kanso 2024-01-23 11:29:39 +02:00 committed by GitHub
commit ec04445427
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 402 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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']];
} }
const hasProhibitedCloningFields = (uid: any): boolean => { return [];
}
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[]>(
(acc, attributeName) => {
const attribute: any = model.attributes[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) =>
getProhibitedCloningFields(componentUID, [
...attributePath,
strapi.getModel(componentUID).info.displayName,
])
),
];
case 'uid': case 'uid':
return true; return [...acc, [attributePath, 'unique']];
default: default:
return attribute?.unique ?? false; 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 };