fix(next): display deleted relations and uploads in version diff views (#12955)

This PR fixes an issue where `relationship` and `upload` fields that had
their document-counterpart deleted would cause a runtime error. This
happens because the version diff components expected a populated
document instead of an id.

Two e2e tests were also added to the existing suite to test that the
diff view is reachable even after deletions.

### Why?
To prevent a runtime error when viewing diffs for documents that have
relationships to deleted documents.

### How?
Adjusting the diff view to accommodate deleted document relations.

Fixes #12915

Before:


[version-diff-collections-before.mp4](https://private-user-images.githubusercontent.com/20878653/458373129-745a5313-b85a-4ef0-a0d4-a13b52994f6f.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTA5Njc3NDEsIm5iZiI6MTc1MDk2NzQ0MSwicGF0aCI6Ii8yMDg3ODY1My80NTgzNzMxMjktNzQ1YTUzMTMtYjg1YS00ZWYwLWEwZDQtYTEzYjUyOTk0ZjZmLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA2MjYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNjI2VDE5NTA0MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTUwOTUzMmJmYWI4YmM4ODZjYjhiNWVkOWE0NDgwODRiYjUxNjVkZmNiOWE2NWM3ODVhMTJiY2ViMGQ4MDE4NjYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.Of4Os9WCeOo_aN2kHNMShEgOc4-mYAwc41_E3YsX1tw)

After:


[versions-collections-after-Posts---Payload.webm](https://github.com/user-attachments/assets/ce4031da-b703-4944-857d-9ff627c58fdd)

Notes:
- I renamed `PopulatedRelationshipValue` to `RelationshipValue` as we
don't actually know if the value is populated. Such as in the case of
when the doc was deleted.
This commit is contained in:
Said Akhrarov
2025-09-06 00:52:15 -04:00
committed by GitHub
parent 794bf8299c
commit bbcdea5450
4 changed files with 87 additions and 32 deletions

View File

@@ -7,7 +7,7 @@ import {
flattenTopLevelFields,
} from 'payload/shared'
import type { PopulatedRelationshipValue } from './index.js'
import type { RelationshipValue } from './index.js'
export const generateLabelFromValue = ({
field,
@@ -20,9 +20,9 @@ export const generateLabelFromValue = ({
locale: string
parentIsLocalized: boolean
req: PayloadRequest
value: PopulatedRelationshipValue
value: RelationshipValue
}): string => {
let relatedDoc: TypeWithID
let relatedDoc: number | string | TypeWithID
let relationTo: string = field.relationTo as string
let valueToReturn: string = ''
@@ -30,7 +30,7 @@ export const generateLabelFromValue = ({
relatedDoc = value.value
relationTo = value.relationTo
} else {
// Non-polymorphic relationship
// Non-polymorphic relationship or deleted document
relatedDoc = value
}
@@ -54,7 +54,11 @@ export const generateLabelFromValue = ({
if (typeof relatedDoc?.[useAsTitle] !== 'undefined') {
valueToReturn = relatedDoc[useAsTitle]
} else {
valueToReturn = String(relatedDoc.id)
valueToReturn = String(
typeof relatedDoc === 'object'
? relatedDoc.id
: `${req.i18n.t('general:untitled')} - ID: ${relatedDoc}`,
)
}
if (

View File

@@ -16,7 +16,9 @@ import { generateLabelFromValue } from './generateLabelFromValue.js'
const baseClass = 'relationship-diff'
export type PopulatedRelationshipValue = { relationTo: string; value: TypeWithID } | TypeWithID
export type RelationshipValue =
| { relationTo: string; value: number | string | TypeWithID }
| (number | string | TypeWithID)
export const Relationship: RelationshipFieldDiffServerComponent = ({
comparisonValue: valueFrom,
@@ -41,8 +43,8 @@ export const Relationship: RelationshipFieldDiffServerComponent = ({
parentIsLocalized={parentIsLocalized}
polymorphic={polymorphic}
req={req}
valueFrom={valueFrom as PopulatedRelationshipValue[] | undefined}
valueTo={valueTo as PopulatedRelationshipValue[] | undefined}
valueFrom={valueFrom as RelationshipValue[] | undefined}
valueTo={valueTo as RelationshipValue[] | undefined}
/>
)
}
@@ -56,8 +58,8 @@ export const Relationship: RelationshipFieldDiffServerComponent = ({
parentIsLocalized={parentIsLocalized}
polymorphic={polymorphic}
req={req}
valueFrom={valueFrom as PopulatedRelationshipValue}
valueTo={valueTo as PopulatedRelationshipValue}
valueFrom={valueFrom as RelationshipValue}
valueTo={valueTo as RelationshipValue}
/>
)
}
@@ -70,8 +72,8 @@ export const SingleRelationshipDiff: React.FC<{
parentIsLocalized: boolean
polymorphic: boolean
req: PayloadRequest
valueFrom: PopulatedRelationshipValue
valueTo: PopulatedRelationshipValue
valueFrom: RelationshipValue
valueTo: RelationshipValue
}> = async (args) => {
const {
field,
@@ -151,8 +153,8 @@ const ManyRelationshipDiff: React.FC<{
parentIsLocalized: boolean
polymorphic: boolean
req: PayloadRequest
valueFrom: PopulatedRelationshipValue[] | undefined
valueTo: PopulatedRelationshipValue[] | undefined
valueFrom: RelationshipValue[] | undefined
valueTo: RelationshipValue[] | undefined
}> = async ({
field,
i18n,
@@ -169,7 +171,7 @@ const ManyRelationshipDiff: React.FC<{
const fromArr = Array.isArray(valueFrom) ? valueFrom : []
const toArr = Array.isArray(valueTo) ? valueTo : []
const makeNodes = (list: PopulatedRelationshipValue[]) =>
const makeNodes = (list: RelationshipValue[]) =>
list.map((val, idx) => (
<RelationshipDocumentDiff
field={field}
@@ -234,7 +236,7 @@ const RelationshipDocumentDiff = ({
relationTo: string
req: PayloadRequest
showPill?: boolean
value: PopulatedRelationshipValue
value: RelationshipValue
}) => {
const localeToUse =
locale ??

View File

@@ -15,6 +15,8 @@ import React from 'react'
const baseClass = 'upload-diff'
type UploadDoc = (FileData & TypeWithID) | string
export const Upload: UploadFieldDiffServerComponent = (args) => {
const {
comparisonValue: valueFrom,
@@ -34,8 +36,8 @@ export const Upload: UploadFieldDiffServerComponent = (args) => {
locale={locale}
nestingLevel={nestingLevel}
req={req}
valueFrom={valueFrom as any}
valueTo={valueTo as any}
valueFrom={valueFrom as UploadDoc[]}
valueTo={valueTo as UploadDoc[]}
/>
)
}
@@ -47,8 +49,8 @@ export const Upload: UploadFieldDiffServerComponent = (args) => {
locale={locale}
nestingLevel={nestingLevel}
req={req}
valueFrom={valueFrom as any}
valueTo={valueTo as any}
valueFrom={valueFrom as UploadDoc}
valueTo={valueTo as UploadDoc}
/>
)
}
@@ -59,8 +61,8 @@ export const HasManyUploadDiff: React.FC<{
locale: string
nestingLevel?: number
req: PayloadRequest
valueFrom: Array<FileData & TypeWithID>
valueTo: Array<FileData & TypeWithID>
valueFrom: Array<UploadDoc>
valueTo: Array<UploadDoc>
}> = async (args) => {
const { field, i18n, locale, nestingLevel, req, valueFrom, valueTo } = args
const ReactDOMServer = (await import('react-dom/server')).default
@@ -74,7 +76,7 @@ export const HasManyUploadDiff: React.FC<{
? valueFrom.map((uploadDoc) => (
<UploadDocumentDiff
i18n={i18n}
key={uploadDoc.id}
key={typeof uploadDoc === 'object' ? uploadDoc.id : uploadDoc}
relationTo={field.relationTo}
req={req}
showCollectionSlug={showCollectionSlug}
@@ -86,7 +88,7 @@ export const HasManyUploadDiff: React.FC<{
? valueTo.map((uploadDoc) => (
<UploadDocumentDiff
i18n={i18n}
key={uploadDoc.id}
key={typeof uploadDoc === 'object' ? uploadDoc.id : uploadDoc}
relationTo={field.relationTo}
req={req}
showCollectionSlug={showCollectionSlug}
@@ -138,8 +140,8 @@ export const SingleUploadDiff: React.FC<{
locale: string
nestingLevel?: number
req: PayloadRequest
valueFrom: FileData & TypeWithID
valueTo: FileData & TypeWithID
valueFrom: UploadDoc
valueTo: UploadDoc
}> = async (args) => {
const { field, i18n, locale, nestingLevel, req, valueFrom, valueTo } = args
@@ -204,12 +206,24 @@ const UploadDocumentDiff = (args: {
relationTo: string
req: PayloadRequest
showCollectionSlug?: boolean
uploadDoc: FileData & TypeWithID
uploadDoc: UploadDoc
}) => {
const { i18n, relationTo, req, showCollectionSlug, uploadDoc } = args
const thumbnailSRC: string =
('thumbnailURL' in uploadDoc && (uploadDoc?.thumbnailURL as string)) || uploadDoc?.url || ''
let thumbnailSRC: string = ''
if (uploadDoc && typeof uploadDoc === 'object' && 'thumbnailURL' in uploadDoc) {
thumbnailSRC =
(typeof uploadDoc.thumbnailURL === 'string' && uploadDoc.thumbnailURL) ||
(typeof uploadDoc.url === 'string' && uploadDoc.url) ||
''
}
let filename: string
if (uploadDoc && typeof uploadDoc === 'object') {
filename = uploadDoc.filename
} else {
filename = `${i18n.t('general:untitled')} - ID: ${uploadDoc as number | string}`
}
let pillLabel: null | string = null
@@ -224,12 +238,12 @@ const UploadDocumentDiff = (args: {
<div
className={`${baseClass}`}
data-enable-match="true"
data-id={uploadDoc?.id}
data-id={typeof uploadDoc === 'object' ? uploadDoc?.id : uploadDoc}
data-relation-to={relationTo}
>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__thumbnail`}>
{thumbnailSRC?.length ? <img alt={uploadDoc?.filename} src={thumbnailSRC} /> : <File />}
{thumbnailSRC?.length ? <img alt={filename} src={thumbnailSRC} /> : <File />}
</div>
{pillLabel && (
<div className={`${baseClass}__pill`} data-enable-match="false">
@@ -237,7 +251,7 @@ const UploadDocumentDiff = (args: {
</div>
)}
<div className={`${baseClass}__info`} data-enable-match="false">
<strong>{uploadDoc?.filename}</strong>
<strong>{filename}</strong>
</div>
</div>
</div>

View File

@@ -1982,6 +1982,41 @@ describe('Versions', () => {
const hiddenField2 = page.locator('[data-field-path="textCannotRead"]')
await expect(hiddenField2).toBeHidden()
})
test('correctly renders diff for relationship fields with deleted relation', async () => {
await payload.delete({
collection: 'draft-posts',
})
await navigateToDiffVersionView()
const diffsToCheck = [
'relationship',
'relationshipHasMany',
'relationshipHasManyPolymorphic',
'relationshipHasManyPolymorphic2',
]
const checkPromises = diffsToCheck.map(async (dataFieldPath) => {
const relation = page.locator(`[data-field-path="${dataFieldPath}"]`)
return expect(relation).toBeVisible()
})
await Promise.all(checkPromises)
})
test('correctly renders diff for upload fields with deleted upload', async () => {
await payload.delete({
collection: 'media',
})
await navigateToDiffVersionView()
const diffsToCheck = ['upload', 'uploadHasMany']
const checkPromises = diffsToCheck.map(async (dataFieldPath) => {
const relation = page.locator(`[data-field-path="${dataFieldPath}"]`)
return expect(relation).toBeVisible()
})
await Promise.all(checkPromises)
})
})
describe('Scheduled publish', () => {