Merge pull request #21428 from strapi/fix/cm-relations

fix: cm relations
This commit is contained in:
Alexandre BODIN 2024-09-27 10:22:45 +02:00 committed by GitHub
commit 9a4af3a3cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 225 additions and 64 deletions

View File

@ -120,6 +120,7 @@ const EditViewPage = () => {
return transformDocument(schema, components)(form);
}, [document, isCreatingDocument, isSingleType, schema, components]);
if (hasError) {
return <Page.Error />;
}

View File

@ -19,9 +19,10 @@ import {
ButtonProps,
} from '@strapi/design-system';
import { Cross, More, WarningCircle } from '@strapi/icons';
import mapValues from 'lodash/fp/mapValues';
import { useIntl } from 'react-intl';
import { useMatch, useNavigate } from 'react-router-dom';
import { styled, DefaultTheme } from 'styled-components';
import { DefaultTheme } from 'styled-components';
import { PUBLISHED_AT_ATTRIBUTE_NAME } from '../../../constants/attributes';
import { SINGLE_TYPES } from '../../../constants/collections';
@ -35,6 +36,7 @@ import { getTranslation } from '../../../utils/translations';
import type { RelationsFormValue } from './FormInputs/Relations';
import type { DocumentActionComponent } from '../../../content-manager';
/* -------------------------------------------------------------------------------------------------
* Types
* -----------------------------------------------------------------------------------------------*/
@ -493,6 +495,22 @@ const DocumentActionModal = ({
);
};
const transformData = (data: Record<string, any>): any => {
if (Array.isArray(data)) {
return data.map(transformData);
}
if (typeof data === 'object' && data !== null) {
if ('apiData' in data) {
return data.apiData;
}
return mapValues(transformData)(data);
}
return data;
};
/* -------------------------------------------------------------------------------------------------
* DocumentActionComponents
* -----------------------------------------------------------------------------------------------*/
@ -643,7 +661,7 @@ const PublishAction: DocumentActionComponent = ({
documentId,
params,
},
formValues
transformData(formValues)
);
if ('data' in res && collectionType !== SINGLE_TYPES) {
@ -797,7 +815,7 @@ const UpdateAction: DocumentActionComponent = ({
documentId: cloneMatch.params.origin!,
params,
},
document
transformData(document)
);
if ('data' in res) {
@ -823,7 +841,7 @@ const UpdateAction: DocumentActionComponent = ({
documentId,
params,
},
document
transformData(document)
);
if (
@ -841,7 +859,7 @@ const UpdateAction: DocumentActionComponent = ({
model,
params,
},
document
transformData(document)
);
if ('data' in res && collectionType !== SINGLE_TYPES) {

View File

@ -84,7 +84,13 @@ function useHandleDisconnect(fieldName: string, consumerName: string) {
}
}
addFieldRow(`${fieldName}.disconnect`, { id: relation.id });
addFieldRow(`${fieldName}.disconnect`, {
id: relation.id,
apiData: {
documentId: relation.documentId,
locale: relation.locale,
},
});
};
return handleDisconnect;
@ -145,14 +151,23 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
const isMorph = props.attribute.relation.toLowerCase().includes('morph');
const isDisabled = isMorph || disabled;
const { id: componentId, uid } = useComponent('RelationsField', ({ uid, id }) => ({ id, uid }));
const { componentId, componentUID } = useComponent('RelationsField', ({ uid, id }) => ({
componentId: id,
componentUID: uid,
}));
const isSubmitting = useForm('RelationsList', (state) => state.isSubmitting);
React.useEffect(() => {
setCurrentPage(1);
}, [isSubmitting]);
/**
* We'll always have a documentId in a created entry, so we look for a componentId first.
* Same with `uid` and `documentModel`.
*/
const id = componentId ? componentId.toString() : documentId;
const model = uid ?? documentModel;
const model = componentUID ?? documentModel;
/**
* The `name` prop is a complete path to the field, e.g. `field1.field2.field3`.
@ -199,6 +214,7 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
const realServerRelationsCount =
'pagination' in data && data.pagination ? data.pagination.total : 0;
/**
* Items that are already connected, but reordered would be in
* this list, so to get an accurate figure, we remove them.
@ -259,6 +275,10 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
const item = {
id: relation.id,
apiData: {
documentId: relation.documentId,
locale: relation.locale,
},
status: relation.status,
/**
* If there's a last item, that's the first key we use to generate out next one.
@ -268,7 +288,7 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
[props.mainField?.name ?? 'documentId']: relation[props.mainField?.name ?? 'documentId'],
label: getRelationLabel(relation, props.mainField),
// @ts-expect-error targetModel does exist on the attribute, but it's not typed.
href: `../${COLLECTION_TYPES}/${props.attribute.targetModel}/${relation.documentId}`,
href: `../${COLLECTION_TYPES}/${props.attribute.targetModel}/${relation.documentId}?${relation.locale ? `plugins[i18n][locale]=${relation.locale}` : ''}`,
};
if (ONE_WAY_RELATIONS.includes(props.attribute.relation)) {
@ -294,7 +314,8 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
<StyledFlex direction="column" alignItems="start" gap={2} width="100%">
<RelationsInput
disabled={isDisabled}
id={id}
// NOTE: we should not default to using the documentId if the component is being created (componentUID is undefined)
id={componentUID ? (componentId ? `${componentId}` : '') : documentId}
label={`${label} ${relationsCount > 0 ? `(${relationsCount})` : ''}`}
model={model}
onChange={handleConnect}
@ -389,7 +410,7 @@ const addLabelAndHref =
// Fallback to `id` if there is no `mainField` value, which will overwrite the above `documentId` property with the exact same data.
[mainField?.name ?? 'documentId']: relation[mainField?.name ?? 'documentId'],
label: getRelationLabel(relation, mainField),
href: `${href}/${relation.documentId}`,
href: `${href}/${relation.documentId}?${relation.locale ? `plugins[i18n][locale]=${relation.locale}` : ''}`,
};
});
@ -454,6 +475,7 @@ const RelationsInput = ({
* individual components. Therefore we split the string and take the last item.
*/
const [targetField] = name.split('.').slice(-1);
searchForTrigger({
model,
targetField,
@ -701,9 +723,7 @@ const RelationsList = ({
*/
const connectedRelations = newData
.reduce<Relation[]>((acc, relation, currentIndex, array) => {
const relationOnServer = serverData.find(
(oldRelation) => oldRelation.documentId === relation.documentId
);
const relationOnServer = serverData.find((oldRelation) => oldRelation.id === relation.id);
const relationInFront = array[currentIndex + 1];
@ -712,11 +732,23 @@ const RelationsList = ({
? {
before: relationInFront.documentId,
locale: relationInFront.locale,
status: relationInFront.status,
status:
'publishedAt' in relationInFront && relationInFront.publishedAt
? 'published'
: 'draft',
}
: { end: true };
const relationWithPosition: Relation = { ...relation, position };
const relationWithPosition: Relation = {
...relation,
...{
apiData: {
documentId: relation.documentId,
locale: relation.locale,
position,
},
},
};
return [...acc, relationWithPosition];
}
@ -899,7 +931,7 @@ const ListItem = ({ data, index, style }: ListItemProps) => {
} = data;
const { formatMessage } = useIntl();
const { href, documentId, label, status } = relations[index];
const { href, id, label, status } = relations[index];
const [{ handlerId, isDragging, handleKeyDown }, relationRef, dropRef, dragRef, dragPreviewRef] =
useDragAndDrop<number, Omit<RelationDragPreviewProps, 'width'>, HTMLDivElement>(
@ -910,7 +942,7 @@ const ListItem = ({ data, index, style }: ListItemProps) => {
item: {
displayedValue: label,
status,
id: documentId,
id: id,
index,
},
onMoveItem: handleMoveItem,

View File

@ -67,11 +67,8 @@ const relationsApi = contentManagerApi.injectEndpoints({
* Relations will always have unique IDs, so we can therefore assume
* that we only need to push the new items to the cache.
*/
const existingIds = currentCache.results.map((item) => item.documentId);
const uniqueNewItems = newItems.results.filter(
(item) => !existingIds.includes(item.documentId)
);
currentCache.results.push(...prepareTempKeys(uniqueNewItems, currentCache.results));
currentCache.results.push(...prepareTempKeys(newItems.results, currentCache.results));
currentCache.pagination = newItems.pagination;
} else if (newItems.pagination.page === 1) {
/**

View File

@ -49,17 +49,39 @@ const sanitizeMainField = (model: any, mainField: any, userAbility: any) => {
return mainField;
};
const addStatusToRelations = async (uid: UID.ContentType, relations: RelationEntity[]) => {
if (!contentTypes.hasDraftAndPublish(strapi.contentTypes[uid])) {
/**
*
* All relations sent to this function should have the same status or no status
*/
const addStatusToRelations = async (targetUid: UID.Schema, relations: RelationEntity[]) => {
if (!contentTypes.hasDraftAndPublish(strapi.getModel(targetUid))) {
return relations;
}
const documentMetadata = getService('document-metadata');
const documentsAvailableStatus = await documentMetadata.getManyAvailableStatus(uid, relations);
if (!relations.length) {
return relations;
}
const firstRelation = relations[0];
const filters: any = {
documentId: { $in: relations.map((r) => r.documentId) },
// NOTE: find the "opposite" status
publishedAt: firstRelation.publishedAt !== null ? { $null: true } : { $notNull: true },
};
const availableStatus = await strapi.query(targetUid).findMany({
select: ['id', 'documentId', 'locale', 'updatedAt', 'createdAt', 'publishedAt'],
filters,
});
return relations.map((relation: RelationEntity) => {
const availableStatuses = documentsAvailableStatus.filter(
(availableDocument: RelationEntity) => availableDocument.documentId === relation.documentId
const availableStatuses = availableStatus.filter(
(availableDocument: RelationEntity) =>
availableDocument.documentId === relation.documentId &&
(relation.locale ? availableDocument.locale === relation.locale : true)
);
return {
@ -396,14 +418,14 @@ export default {
attribute,
targetField,
fieldsToSelect,
source: {
schema: { uid: sourceUid },
},
target: {
schema: { uid: targetUid },
},
status,
source: { schema: sourceSchema },
target: { schema: targetSchema },
} = await this.extractAndValidateRequestInfo(ctx, id);
const { uid: sourceUid } = sourceSchema;
const { uid: targetUid } = targetSchema;
const permissionQuery = await getService('permission-checker')
.create({ userAbility, model: targetUid })
.sanitizedQuery.read({ fields: fieldsToSelect });
@ -424,6 +446,23 @@ export default {
// Ensure response is an array
.then((res) => ({ results: res ? [res] : [] }));
const filters: {
publishedAt?: Record<string, any>;
} = {};
if (sourceSchema?.options?.draftAndPublish) {
if (targetSchema?.options?.draftAndPublish) {
if (status === 'published') {
filters.publishedAt = { $notNull: true };
} else {
filters.publishedAt = { $null: true };
}
}
} else if (targetSchema?.options?.draftAndPublish) {
// NOTE: we must return the drafts as some targets might not have a published version yet
filters.publishedAt = { $null: true };
}
/**
* If user does not have access to specific relations (custom conditions),
* only the ids of the relations are returned.
@ -434,10 +473,11 @@ export default {
* The response contains the union of the two queries.
*/
const res = await loadRelations({ id: entryId }, targetField, {
select: ['id', 'documentId', 'locale', 'publishedAt'],
select: ['id', 'documentId', 'locale', 'publishedAt', 'updatedAt'],
ordering: 'desc',
page: ctx.request.query.page,
pageSize: ctx.request.query.pageSize,
filters,
});
/**
@ -458,6 +498,7 @@ export default {
ordering: 'desc',
});
// NOTE: the order is very import to make sure sanitized relations are kept in priority
const relationsUnion = uniqBy('id', concat(sanitizedRes.results, res.results));
ctx.body = {

View File

@ -7,11 +7,11 @@ import type { DocumentMetadata } from '../../../shared/contracts/collection-type
import { getValidatableFieldsPopulate } from './utils/populate';
export interface DocumentVersion {
id: number;
id: string | number;
documentId: Modules.Documents.ID;
locale: string;
updatedAt: string | null | Date;
publishedAt: string | null | Date;
locale?: string;
updatedAt?: string | null | Date;
publishedAt?: string | null | Date;
}
const AVAILABLE_STATUS_FIELDS = [
@ -86,7 +86,9 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
const versionsByLocale = groupBy('locale', allVersions);
// Delete the current locale
if (version.locale) {
delete versionsByLocale[version.locale];
}
// For each locale, get the ones with the same status
// There will not be a draft and a version counterpart if the content

View File

@ -1,8 +1,11 @@
import '@strapi/types';
import { DocumentManagerService } from 'src/services/document-manager';
import DocumentMetadata from 'src/services/document-metadata';
type Services = {
'document-manager': DocumentManagerService;
'document-metadata': typeof DocumentMetadata;
[key: string]: any;
};

View File

@ -291,7 +291,10 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
]);
// Load any unidirectional relation targetting the old published entries
const relationsToSync = await unidirectionalRelations.load(uid, oldPublishedVersions);
const relationsToSync = await unidirectionalRelations.load(uid, {
newVersions: draftsToPublish,
oldVersions: oldPublishedVersions,
});
// Delete old published versions
await async.map(oldPublishedVersions, (entry: any) => entries.delete(entry.id));
@ -302,7 +305,11 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
);
// Sync unidirectional relations with the new published entries
await unidirectionalRelations.sync(oldPublishedVersions, publishedEntries, relationsToSync);
await unidirectionalRelations.sync(
[...oldPublishedVersions, ...draftsToPublish],
publishedEntries,
relationsToSync
);
publishedEntries.forEach(emitEvent('entry.publish'));
@ -358,7 +365,10 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
]);
// Load any unidirectional relation targeting the old drafts
const relationsToSync = await unidirectionalRelations.load(uid, oldDrafts);
const relationsToSync = await unidirectionalRelations.load(uid, {
newVersions: versionsToDraft,
oldVersions: oldDrafts,
});
// Delete old drafts
await async.map(oldDrafts, (entry: any) => entries.delete(entry.id));
@ -369,7 +379,11 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
);
// Sync unidirectional relations with the new draft entries
await unidirectionalRelations.sync(oldDrafts, draftEntries, relationsToSync);
await unidirectionalRelations.sync(
[...oldDrafts, ...versionsToDraft],
draftEntries,
relationsToSync
);
draftEntries.forEach(emitEvent('entry.draft-discard'));
return { documentId, entries: draftEntries };

View File

@ -1,18 +1,19 @@
/* eslint-disable no-continue */
import { keyBy } from 'lodash/fp';
import { keyBy, omit } from 'lodash/fp';
import { UID, Schema } from '@strapi/types';
interface LoadContext {
oldVersions: { id: string; locale: string }[];
newVersions: { id: string; locale: string }[];
}
/**
* Loads lingering relations that need to be updated when overriding a published or draft entry.
* This is necessary because the relations are uni-directional and the target entry is not aware of the source entry.
* This is not the case for bi-directional relations, where the target entry is also linked to the source entry.
*
* @param uid The content type uid
* @param oldEntries The old entries that are being overridden
* @returns An array of relations that need to be updated with the join table reference.
*/
const load = async (uid: UID.ContentType, oldEntries: { id: string; locale: string }[]) => {
const load = async (uid: UID.ContentType, { oldVersions, newVersions }: LoadContext) => {
const updates = [] as any;
// Iterate all components and content types to find relations that need to be updated
@ -27,29 +28,80 @@ const load = async (uid: UID.ContentType, oldEntries: { id: string; locale: stri
/**
* Only consider unidirectional relations
*/
if (attribute.type !== 'relation') continue;
if (attribute.target !== uid) continue;
if (attribute.inversedBy || attribute.mappedBy) continue;
const joinTable = attribute.joinTable;
// TODO: joinColumn relations
if (!joinTable) continue;
if (
attribute.type !== 'relation' ||
attribute.target !== uid ||
attribute.inversedBy ||
attribute.mappedBy
) {
continue;
}
const { name } = joinTable.inverseJoinColumn;
// TODO: joinColumn relations
const joinTable = attribute.joinTable;
if (!joinTable) {
continue;
}
const { name: sourceColumnName } = joinTable.joinColumn;
const { name: targetColumnName } = joinTable.inverseJoinColumn;
/**
* Load all relations that need to be updated
*/
const oldEntriesIds = oldEntries.map((entry) => entry.id);
const relations = await strapi.db
// NOTE: when the model has draft and publish, we can assume relation are only draft to draft & published to published
const ids = oldVersions.map((entry) => entry.id);
const oldVersionsRelations = await strapi.db
.getConnection()
.select('*')
.from(joinTable.name)
.whereIn(name, oldEntriesIds)
.whereIn(targetColumnName, ids)
.transacting(trx);
if (relations.length === 0) continue;
if (oldVersionsRelations.length > 0) {
updates.push({ joinTable, relations: oldVersionsRelations });
}
updates.push({ joinTable, relations });
/**
* if publishing
* if published version exists
* updated published versions links
* else
* create link to newly published version
*
* if discarding
* if published version link exists & not draft version link
* create link to new draft version
*/
if (!model.options?.draftAndPublish) {
const ids = newVersions.map((entry) => entry.id);
const newVersionsRelations = await strapi.db
.getConnection()
.select('*')
.from(joinTable.name)
.whereIn(targetColumnName, ids)
.transacting(trx);
if (newVersionsRelations.length > 0) {
// when publishing a draft that doesn't have a published version yet,
// copy the links to the draft over to the published version
// when discarding a published version, if no drafts exists
const discardToAdd = newVersionsRelations
.filter((relation) => {
const matchingOldVerion = oldVersionsRelations.find((oldRelation) => {
return oldRelation[sourceColumnName] === relation[sourceColumnName];
});
return !matchingOldVerion;
})
.map(omit('id'));
updates.push({ joinTable, relations: discardToAdd });
}
}
}
}
});
@ -89,8 +141,9 @@ const sync = async (
// Iterate old relations that are deleted and insert the new ones
for (const { joinTable, relations } of oldRelations) {
// Update old ids with the new ones
const newRelations = relations.map((relation) => {
const column = joinTable.inverseJoinColumn.name;
const newRelations = relations.map((relation) => {
const newId = oldEntriesMap[relation[column]];
return { ...relation, [column]: newId };
});

View File

@ -138,7 +138,7 @@ describe('Relation permissions', () => {
const shopEntry = await createEntry(
'api::shop.shop',
{ name: 'Shop', products: [product.id] },
{ name: 'Shop', products: [product.documentId] },
populateShop
);
shop = shopEntry.data;