diff --git a/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx b/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx
index 6d3574112b..f93e7f279c 100644
--- a/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx
+++ b/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx
@@ -120,6 +120,7 @@ const EditViewPage = () => {
return transformDocument(schema, components)(form);
}, [document, isCreatingDocument, isSingleType, schema, components]);
+
if (hasError) {
return ;
}
diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx
index 93cf0d1ea7..ca639613a7 100644
--- a/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx
+++ b/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx
@@ -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): 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) {
diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Relations.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Relations.tsx
index 6e31f19247..b0a4e78907 100644
--- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Relations.tsx
+++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Relations.tsx
@@ -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(
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(
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(
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(
[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(
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((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, HTMLDivElement>(
@@ -910,7 +942,7 @@ const ListItem = ({ data, index, style }: ListItemProps) => {
item: {
displayedValue: label,
status,
- id: documentId,
+ id: id,
index,
},
onMoveItem: handleMoveItem,
diff --git a/packages/core/content-manager/admin/src/services/relations.ts b/packages/core/content-manager/admin/src/services/relations.ts
index de20c255de..7592c59cc8 100644
--- a/packages/core/content-manager/admin/src/services/relations.ts
+++ b/packages/core/content-manager/admin/src/services/relations.ts
@@ -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) {
/**
diff --git a/packages/core/content-manager/server/src/controllers/relations.ts b/packages/core/content-manager/server/src/controllers/relations.ts
index 4abc5f14aa..c7e0112aa0 100644
--- a/packages/core/content-manager/server/src/controllers/relations.ts
+++ b/packages/core/content-manager/server/src/controllers/relations.ts
@@ -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;
+ } = {};
+
+ 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 = {
diff --git a/packages/core/content-manager/server/src/services/document-metadata.ts b/packages/core/content-manager/server/src/services/document-metadata.ts
index 5d07ba722c..46bf820f3e 100644
--- a/packages/core/content-manager/server/src/services/document-metadata.ts
+++ b/packages/core/content-manager/server/src/services/document-metadata.ts
@@ -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
- delete versionsByLocale[version.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
diff --git a/packages/core/content-manager/server/src/utils/index.ts b/packages/core/content-manager/server/src/utils/index.ts
index d67a1723b3..d90a1e2d32 100644
--- a/packages/core/content-manager/server/src/utils/index.ts
+++ b/packages/core/content-manager/server/src/utils/index.ts
@@ -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;
};
diff --git a/packages/core/core/src/services/document-service/repository.ts b/packages/core/core/src/services/document-service/repository.ts
index 374e1dc0cd..8b5e8d8fca 100644
--- a/packages/core/core/src/services/document-service/repository.ts
+++ b/packages/core/core/src/services/document-service/repository.ts
@@ -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 };
diff --git a/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts b/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts
index cec816d624..389eacb07f 100644
--- a/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts
+++ b/packages/core/core/src/services/document-service/utils/unidirectional-relations.ts
@@ -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 column = joinTable.inverseJoinColumn.name;
+
const newRelations = relations.map((relation) => {
- const column = joinTable.inverseJoinColumn.name;
const newId = oldEntriesMap[relation[column]];
return { ...relation, [column]: newId };
});
diff --git a/tests/api/core/content-manager/api/relations-permissions.test.api.js b/tests/api/core/content-manager/api/relations-permissions.test.api.js
index ac3bcbd991..9fdb33a066 100644
--- a/tests/api/core/content-manager/api/relations-permissions.test.api.js
+++ b/tests/api/core/content-manager/api/relations-permissions.test.api.js
@@ -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;