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;