Skip to content

Commit 57a0edc

Browse files
authored
fix: use latest draft version data when trashing unpublished documents (#15981)
# Overview Fixes document data being silently lost when bulk-trashing draft-only documents on collections with `trash: true` and `versions.drafts` enabled. Localized fields are the most visible symptom, but any field data that exists only in the versions table (i.e. never published) is affected. ## Key Changes - In the bulk `update` operation, `queryDrafts` (versions table) is now also used when fetching documents for a trash attempt, not only when `shouldSaveDraft` is true ## Root Cause Draft saves skip `updateOne` on the main collection table (`isSavingDraft === true`), so the main table only reflects the last published state. When bulk-trashing, the operation was reading `docWithLocales` from the main table via `find()` — picking up stale/empty data — and creating a new version from that stale snapshot. Single-document trash (`updateByID`) was unaffected because it uses `getLatestCollectionVersion()`, which always reads from the versions table. ## Fix ```typescript // Before if (hasDraftsEnabled(collectionConfig) && shouldSaveDraft) { // After if (hasDraftsEnabled(collectionConfig) && (shouldSaveDraft || isTrashAttempt)) { ``` Fixes #15980
1 parent 04a4b0a commit 57a0edc

6 files changed

Lines changed: 252 additions & 4 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export const updateOperation = async <
179179

180180
let docs
181181

182-
if (hasDraftsEnabled(collectionConfig) && shouldSaveDraft) {
182+
if (hasDraftsEnabled(collectionConfig) && (shouldSaveDraft || isTrashAttempt)) {
183183
const versionsWhere = appendVersionToQueryKey(fullWhere)
184184

185185
await validateQueryPaths({

test/trash/collections/Posts/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export const Posts: CollectionConfig = {
1414
type: 'text',
1515
required: true,
1616
},
17+
{
18+
name: 'localizedField',
19+
type: 'text',
20+
localized: true,
21+
},
1722
],
1823
versions: {
1924
drafts: true,

test/trash/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export default buildConfigWithDefaults({
2020
baseDir: path.resolve(dirname),
2121
},
2222
},
23+
localization: {
24+
locales: ['en', 'es'],
25+
defaultLocale: 'en',
26+
},
2327
editor: lexicalEditor({}),
2428

2529
onInit: async (payload) => {

test/trash/e2e.spec.ts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import type { PayloadTestSDK } from '../__helpers/shared/sdk/index.js'
1010
import type { Config, Post } from './payload-types.js'
1111

1212
import {
13+
changeLocale,
1314
closeAllToasts,
1415
ensureCompilationIsDone,
1516
initPageConsoleErrorCatch,
16-
throttleTest,
1717
} from '../__helpers/e2e/helpers.js'
1818
import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
1919
import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
@@ -1167,6 +1167,154 @@ describe('Trash', () => {
11671167
)
11681168
})
11691169
})
1170+
1171+
test('should preserve localized field data for all locales when trashing a draft document from the edit view', async ({
1172+
page,
1173+
}) => {
1174+
const localizedFieldValueEN = 'Localized Draft Content EN'
1175+
const localizedFieldValueES = 'Localized Draft Content ES'
1176+
1177+
const draftPost = await payload.create({
1178+
collection: postsSlug,
1179+
data: {
1180+
title: 'Draft with Localized Field',
1181+
_status: 'draft',
1182+
},
1183+
})
1184+
1185+
await payload.update({
1186+
collection: postsSlug,
1187+
id: draftPost.id,
1188+
locale: 'en',
1189+
data: {
1190+
localizedField: localizedFieldValueEN,
1191+
_status: 'draft',
1192+
},
1193+
draft: true,
1194+
})
1195+
1196+
await payload.update({
1197+
collection: postsSlug,
1198+
id: draftPost.id,
1199+
locale: 'es',
1200+
data: {
1201+
localizedField: localizedFieldValueES,
1202+
_status: 'draft',
1203+
},
1204+
draft: true,
1205+
})
1206+
1207+
await page.goto(postsUrl.edit(draftPost.id))
1208+
1209+
const threeDotMenu = page.locator('.doc-controls__popup')
1210+
await expect(threeDotMenu).toBeVisible()
1211+
await threeDotMenu.click()
1212+
1213+
await page.locator('.popup__content #action-delete').click()
1214+
1215+
await page.locator('.delete-document #confirm-action').click()
1216+
1217+
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
1218+
'Post "Draft with Localized Field" moved to trash.',
1219+
)
1220+
await closeAllToasts(page)
1221+
1222+
await page.goto(postsUrl.trashEdit(draftPost.id))
1223+
await page.waitForURL(/\/posts\/trash\//)
1224+
1225+
const localizedFieldInput = page.locator('#field-localizedField')
1226+
await expect(localizedFieldInput).toBeVisible()
1227+
await expect(localizedFieldInput).toHaveValue(localizedFieldValueEN)
1228+
1229+
await changeLocale(page, 'es')
1230+
await expect(localizedFieldInput).toHaveValue(localizedFieldValueES)
1231+
})
1232+
1233+
test('should preserve localized field data for all locales when bulk trashing draft documents', async ({
1234+
page,
1235+
}) => {
1236+
const localizedFieldValueEN = 'Localized Draft Content EN'
1237+
const localizedFieldValueES = 'Localized Draft Content ES'
1238+
1239+
// Create a draft post without localized data initially
1240+
const draftPost = await payload.create({
1241+
collection: postsSlug,
1242+
data: {
1243+
title: 'Draft with Localized Field',
1244+
_status: 'draft',
1245+
},
1246+
})
1247+
1248+
// Update en locale as draft - isSavingDraft = true skips updateOne on the main table,
1249+
// storing localized data only in the versions table
1250+
await payload.update({
1251+
collection: postsSlug,
1252+
id: draftPost.id,
1253+
locale: 'en',
1254+
data: {
1255+
localizedField: localizedFieldValueEN,
1256+
_status: 'draft',
1257+
},
1258+
draft: true,
1259+
})
1260+
1261+
// Update es locale as draft
1262+
await payload.update({
1263+
collection: postsSlug,
1264+
id: draftPost.id,
1265+
locale: 'es',
1266+
data: {
1267+
localizedField: localizedFieldValueES,
1268+
_status: 'draft',
1269+
},
1270+
draft: true,
1271+
})
1272+
1273+
await page.goto(postsUrl.list)
1274+
1275+
const postRow = page.locator('.table tr', { hasText: 'Draft with Localized Field' })
1276+
1277+
await expect(postRow.locator('.cell-localizedField')).toHaveText(localizedFieldValueEN)
1278+
1279+
await changeLocale(page, 'es')
1280+
await expect(postRow.locator('.cell-localizedField')).toHaveText(localizedFieldValueES)
1281+
1282+
await changeLocale(page, 'en')
1283+
1284+
await postRow.locator('.cell-_select input').check()
1285+
await page.locator('.list-selection__button[aria-label="Delete"]').click()
1286+
1287+
await page.locator('#confirm-delete-many-docs #confirm-action').click()
1288+
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
1289+
'1 Post moved to trash.',
1290+
)
1291+
await closeAllToasts(page)
1292+
1293+
await page.locator('#trash-view-pill').click()
1294+
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
1295+
1296+
const trashedRow = page.locator('.table tr', { hasText: 'Draft with Localized Field' })
1297+
1298+
await expect(trashedRow.locator('.cell-localizedField')).toHaveText(localizedFieldValueEN)
1299+
1300+
await changeLocale(page, 'es')
1301+
await expect(trashedRow.locator('.cell-localizedField')).toHaveText(localizedFieldValueES)
1302+
1303+
await changeLocale(page, 'en')
1304+
1305+
const cellLink = trashedRow.locator('.cell-title a')
1306+
const linkURL = await cellLink.getAttribute('href')
1307+
await page.goto(`${serverURL}${linkURL}`)
1308+
1309+
await page.waitForURL(/\/posts\/trash\//)
1310+
1311+
const localizedFieldInput = page.locator('#field-localizedField')
1312+
await expect(localizedFieldInput).toBeVisible()
1313+
await expect(localizedFieldInput).toHaveValue(localizedFieldValueEN)
1314+
1315+
await changeLocale(page, 'es')
1316+
await expect(localizedFieldInput).toHaveValue(localizedFieldValueES)
1317+
})
11701318
})
11711319

11721320
async function createPostDoc(data: RequiredDataFromCollectionSlug<'posts'>): Promise<Post> {

test/trash/int.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,82 @@ describe('trash', () => {
11471147
expect(result.totalDocs).toEqual(1) // Only postsDocTwo
11481148
})
11491149
})
1150+
1151+
it('should preserve localized field data when bulk trashing draft documents', async () => {
1152+
const localizedFieldValueEN = 'Localized Draft Content EN'
1153+
const localizedFieldValueES = 'Localized Draft Content ES'
1154+
1155+
const post = await payload.create({
1156+
collection: postsSlug,
1157+
data: {
1158+
title: 'Draft with Localized Field',
1159+
_status: 'draft',
1160+
},
1161+
})
1162+
1163+
// Update en locale as draft - isSavingDraft = true skips updateOne on the main table,
1164+
// storing localized data only in the versions table
1165+
await payload.update({
1166+
collection: postsSlug,
1167+
id: post.id,
1168+
locale: 'en',
1169+
data: {
1170+
localizedField: localizedFieldValueEN,
1171+
_status: 'draft',
1172+
},
1173+
draft: true,
1174+
})
1175+
1176+
await payload.update({
1177+
collection: postsSlug,
1178+
id: post.id,
1179+
locale: 'es',
1180+
data: {
1181+
localizedField: localizedFieldValueES,
1182+
_status: 'draft',
1183+
},
1184+
draft: true,
1185+
})
1186+
1187+
// Bulk trash the document (simulates list view "Move to Trash")
1188+
// This reads from the main table which has stale/empty localizedField
1189+
const trashResult = await payload.update({
1190+
collection: postsSlug,
1191+
data: {
1192+
deletedAt: new Date().toISOString(),
1193+
},
1194+
where: {
1195+
id: {
1196+
equals: post.id,
1197+
},
1198+
},
1199+
})
1200+
1201+
expect(trashResult.docs).toHaveLength(1)
1202+
expect(trashResult.docs[0]?.deletedAt).toBeTruthy()
1203+
1204+
// Fetch the latest draft version of the trashed document for each locale
1205+
const trashedDocEN = await payload.findByID({
1206+
collection: postsSlug,
1207+
id: post.id,
1208+
locale: 'en',
1209+
draft: true,
1210+
trash: true,
1211+
})
1212+
1213+
const trashedDocES = await payload.findByID({
1214+
collection: postsSlug,
1215+
id: post.id,
1216+
locale: 'es',
1217+
draft: true,
1218+
trash: true,
1219+
})
1220+
1221+
// localizedField should be preserved from the latest draft version for both locales,
1222+
// not lost due to stale main table data being used during bulk trash
1223+
expect(trashedDocEN.localizedField).toBe(localizedFieldValueEN)
1224+
expect(trashedDocES.localizedField).toBe(localizedFieldValueES)
1225+
})
11501226
})
11511227

11521228
describe('REST API', () => {

test/trash/payload-types.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,13 @@ export interface Config {
9292
db: {
9393
defaultIDType: string;
9494
};
95-
fallbackLocale: null;
95+
fallbackLocale: ('false' | 'none' | 'null') | false | null | ('en' | 'es') | ('en' | 'es')[];
9696
globals: {};
9797
globalsSelect: {};
98-
locale: null;
98+
locale: 'en' | 'es';
99+
widgets: {
100+
collections: CollectionsWidget;
101+
};
99102
user: User;
100103
jobs: {
101104
tasks: unknown;
@@ -138,6 +141,7 @@ export interface Page {
138141
export interface Post {
139142
id: string;
140143
title: string;
144+
localizedField?: string | null;
141145
updatedAt: string;
142146
createdAt: string;
143147
deletedAt?: string | null;
@@ -297,6 +301,7 @@ export interface PagesSelect<T extends boolean = true> {
297301
*/
298302
export interface PostsSelect<T extends boolean = true> {
299303
title?: T;
304+
localizedField?: T;
300305
updatedAt?: T;
301306
createdAt?: T;
302307
deletedAt?: T;
@@ -389,6 +394,16 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
389394
updatedAt?: T;
390395
createdAt?: T;
391396
}
397+
/**
398+
* This interface was referenced by `Config`'s JSON-Schema
399+
* via the `definition` "collections_widget".
400+
*/
401+
export interface CollectionsWidget {
402+
data?: {
403+
[k: string]: unknown;
404+
};
405+
width: 'full';
406+
}
392407
/**
393408
* This interface was referenced by `Config`'s JSON-Schema
394409
* via the `definition` "auth".

0 commit comments

Comments
 (0)