Skip to content

Commit fad992c

Browse files
authored
fix: fix draft save and duplicate behaviour on upload-enabled collections (#16853)
Backport of #16844
1 parent 6707e85 commit fad992c

10 files changed

Lines changed: 558 additions & 70 deletions

File tree

packages/payload/src/collections/operations/create.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export const createOperation = async <
163163
collection,
164164
config,
165165
data,
166+
draft: isSavingDraft,
166167
isDuplicating: Boolean(duplicateFromID),
167168
operation: 'create',
168169
originalDoc: duplicatedFromDoc,

packages/payload/src/collections/operations/utilities/update.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -169,14 +169,23 @@ export const updateDocument = async <
169169
// Delete any associated files
170170
// /////////////////////////////////////
171171

172-
await deleteAssociatedFiles({
173-
collectionConfig,
174-
config,
175-
doc: docWithLocales,
176-
files: filesToUpload,
177-
overrideDelete: false,
178-
req,
179-
})
172+
// When saving a draft on a document whose latest version is published, the file
173+
// referenced by docWithLocales is still actively used by the published main document.
174+
// Deleting it here would break the published document's file even though no publish
175+
// is happening. Only skip deletion in this case; when the latest version is already a
176+
// draft, it is safe to delete the old draft file as it is being replaced.
177+
const isDraftOverPublished = isSavingDraft && docWithLocales._status === 'published'
178+
179+
if (!isDraftOverPublished) {
180+
await deleteAssociatedFiles({
181+
collectionConfig,
182+
config,
183+
doc: docWithLocales,
184+
files: filesToUpload,
185+
overrideDelete: false,
186+
req,
187+
})
188+
}
180189

181190
// /////////////////////////////////////
182191
// beforeValidate - Fields

packages/payload/src/uploads/generateFileData.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Args<T> = {
2525
collection: Collection
2626
config: SanitizedConfig
2727
data: T
28+
draft?: boolean
2829
isDuplicating?: boolean
2930
operation: 'create' | 'update'
3031
originalDoc?: T
@@ -68,6 +69,7 @@ const shouldReupload = (
6869
export const generateFileData = async <T>({
6970
collection: { config: collectionConfig },
7071
data,
72+
draft,
7173
isDuplicating,
7274
operation,
7375
originalDoc,
@@ -422,6 +424,7 @@ export const generateFileData = async <T>({
422424
newData = {
423425
...newData,
424426
...fileData,
427+
...(draft ? { _status: 'draft' } : {}),
425428
}
426429

427430
return {

test/versions/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
uploads
22
uploads2
3+
uploads-draft
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import path from 'path'
4+
import { fileURLToPath } from 'url'
5+
6+
import { draftWithUploadCollectionSlug } from '../slugs.js'
7+
8+
const filename = fileURLToPath(import.meta.url)
9+
const dirname = path.dirname(filename)
10+
11+
export const DraftsWithUpload: CollectionConfig = {
12+
slug: draftWithUploadCollectionSlug,
13+
upload: {
14+
staticDir: path.resolve(dirname, './uploads-draft'),
15+
},
16+
versions: {
17+
drafts: true,
18+
},
19+
fields: [
20+
{
21+
name: 'alt',
22+
type: 'text',
23+
},
24+
],
25+
}

test/versions/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import DraftWithMax from './collections/DraftsWithMax.js'
1818
import DraftsWithValidate from './collections/DraftsWithValidate.js'
1919
import ErrorOnUnpublish from './collections/ErrorOnUnpublish.js'
2020
import LocalizedPosts from './collections/Localized.js'
21+
import { DraftsWithUpload } from './collections/DraftsWithUpload.js'
2122
import { Media } from './collections/Media.js'
2223
import { Media2 } from './collections/Media2.js'
2324
import Posts from './collections/Posts.js'
@@ -62,6 +63,7 @@ export default buildConfigWithDefaults({
6263
CustomIDs,
6364
Diff,
6465
TextCollection,
66+
DraftsWithUpload,
6567
Media,
6668
Media2,
6769
],

test/versions/e2e.spec.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
draftWithChangeHookCollectionSlug,
7575
draftWithMaxCollectionSlug,
7676
draftWithMaxGlobalSlug,
77+
draftWithUploadCollectionSlug,
7778
draftWithValidateCollectionSlug,
7879
errorOnUnpublishSlug,
7980
localizedCollectionSlug,
@@ -1131,6 +1132,128 @@ describe('Versions', () => {
11311132
})
11321133
})
11331134

1135+
describe('draft upload collections', () => {
1136+
let uploadURL: AdminUrlUtil
1137+
1138+
beforeAll(() => {
1139+
uploadURL = new AdminUrlUtil(serverURL, draftWithUploadCollectionSlug)
1140+
})
1141+
1142+
test('should keep published status after reuploading a file and saving as draft', async () => {
1143+
const publishedDoc = await payload.create({
1144+
collection: draftWithUploadCollectionSlug,
1145+
data: {
1146+
_status: 'published',
1147+
alt: 'Original image',
1148+
},
1149+
filePath: path.resolve(dirname, './image.jpg'),
1150+
})
1151+
1152+
await page.goto(uploadURL.edit(publishedDoc.id))
1153+
await waitForFormReady(page)
1154+
1155+
await expect(page.locator('.doc-controls__status .status__value')).toContainText('Published')
1156+
1157+
// The file input is only rendered once the existing file is removed.
1158+
// Click the remove button on the current file to reveal the file input.
1159+
await page.locator('.file-details__remove').click()
1160+
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './image.png'), {
1161+
force: true,
1162+
})
1163+
1164+
await saveDocAndAssert(page, '#action-save-draft')
1165+
1166+
await expect(page.locator('.doc-controls__status .status__value')).toContainText('Changed')
1167+
1168+
await expect(async () => {
1169+
const { docs } = await payload.find({
1170+
collection: draftWithUploadCollectionSlug,
1171+
where: { id: { equals: publishedDoc.id } },
1172+
})
1173+
expect(docs[0]!._status).toStrictEqual('published')
1174+
expect(docs[0]!.filename).toStrictEqual(publishedDoc.filename)
1175+
}).toPass({ timeout: POLL_TOPASS_TIMEOUT })
1176+
})
1177+
1178+
test('should create a draft version with the new file without altering the published doc', async () => {
1179+
const publishedDoc = await payload.create({
1180+
collection: draftWithUploadCollectionSlug,
1181+
data: {
1182+
_status: 'published',
1183+
alt: 'Original image',
1184+
},
1185+
filePath: path.resolve(dirname, './image.jpg'),
1186+
})
1187+
1188+
await page.goto(uploadURL.edit(publishedDoc.id))
1189+
await waitForFormReady(page)
1190+
1191+
// The file input is only rendered once the existing file is removed.
1192+
// Click the remove button on the current file to reveal the file input.
1193+
await page.locator('.file-details__remove').click()
1194+
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './image.png'), {
1195+
force: true,
1196+
})
1197+
1198+
await saveDocAndAssert(page, '#action-save-draft')
1199+
1200+
await expect(async () => {
1201+
const { docs: draftDocs } = await payload.find({
1202+
collection: draftWithUploadCollectionSlug,
1203+
draft: true,
1204+
where: { id: { equals: publishedDoc.id } },
1205+
})
1206+
expect(draftDocs[0]!._status).toStrictEqual('draft')
1207+
expect(draftDocs[0]!.filename).not.toStrictEqual(publishedDoc.filename)
1208+
1209+
const { docs: mainDocs } = await payload.find({
1210+
collection: draftWithUploadCollectionSlug,
1211+
where: { id: { equals: publishedDoc.id } },
1212+
})
1213+
expect(mainDocs[0]!.filename).toStrictEqual(publishedDoc.filename)
1214+
}).toPass({ timeout: POLL_TOPASS_TIMEOUT })
1215+
})
1216+
1217+
test('should create a draft when duplicating a published upload document', async () => {
1218+
const publishedDoc = await payload.create({
1219+
collection: draftWithUploadCollectionSlug,
1220+
data: {
1221+
_status: 'published',
1222+
alt: 'Original image',
1223+
},
1224+
filePath: path.resolve(dirname, './image.jpg'),
1225+
})
1226+
1227+
await page.goto(uploadURL.edit(publishedDoc.id))
1228+
await waitForFormReady(page)
1229+
1230+
await openDocControls(page)
1231+
await page.locator('#action-duplicate').click()
1232+
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
1233+
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain(publishedDoc.id)
1234+
1235+
await expect(page.locator('.doc-controls__status .status__value')).toContainText('Draft')
1236+
await waitForFormReady(page)
1237+
1238+
const duplicatedDocID = new URL(page.url()).pathname.split('/').pop()
1239+
1240+
await expect(async () => {
1241+
const { docs: draftDocs } = await payload.find({
1242+
collection: draftWithUploadCollectionSlug,
1243+
draft: true,
1244+
where: { id: { equals: duplicatedDocID } },
1245+
})
1246+
expect(draftDocs[0]!._status).toStrictEqual('draft')
1247+
1248+
const { docs: mainDocs } = await payload.find({
1249+
collection: draftWithUploadCollectionSlug,
1250+
where: { id: { equals: duplicatedDocID } },
1251+
})
1252+
expect(mainDocs[0]!._status).toStrictEqual('draft')
1253+
}).toPass({ timeout: POLL_TOPASS_TIMEOUT })
1254+
})
1255+
})
1256+
11341257
describe('draft globals', () => {
11351258
test('should show global versions view level action in globals versions view', async () => {
11361259
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)

0 commit comments

Comments
 (0)