fix(next): unable to view trashed documents when group-by is enabled (#13300)

### What?

- Fixed an issue where group-by enabled collections with `trash: true`
were not showing trashed documents in the collection’s trash view.
- Ensured that the `trash` query argument is properly passed to the
`findDistinct` call within `handleGroupBy`, allowing trashed documents
to be included in grouped list views.

### Why?

Previously, when viewing the trash view of a collection with both
**group-by** and **trash** enabled, trashed documents would not appear.
This was caused by the `trash` argument not being forwarded to
`findDistinct` in `handleGroupBy`, which resulted in empty or incorrect
group-by results.

### How?

- Passed the `trash` flag through all relevant `findDistinct` and `find`
calls in `handleGroupBy`.
This commit is contained in:
Patrik
2025-07-28 14:29:04 -04:00
committed by GitHub
parent 5c94d2dc71
commit aff2ce1b9b
11 changed files with 150 additions and 13 deletions

View File

@@ -22,6 +22,7 @@ export const handleGroupBy = async ({
enableRowSelections,
query,
req,
trash = false,
user,
where: whereWithMergedSearch,
}: {
@@ -34,6 +35,7 @@ export const handleGroupBy = async ({
enableRowSelections?: boolean
query?: ListQuery
req: PayloadRequest
trash?: boolean
user: any
where: Where
}): Promise<{
@@ -88,6 +90,7 @@ export const handleGroupBy = async ({
populate,
req,
sort: query?.groupBy,
trash,
where: whereWithMergedSearch,
})
@@ -127,6 +130,7 @@ export const handleGroupBy = async ({
// Note: if we wanted to enable table-by-table sorting, we could use this:
// sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort,
sort: query?.sort,
trash,
user,
where: {
...(whereWithMergedSearch || {}),

View File

@@ -205,6 +205,7 @@ export const renderListView = async (
enableRowSelections,
query,
req,
trash: query?.trash === true,
user,
where: whereWithMergedSearch,
}))

View File

@@ -11,13 +11,14 @@ import { findDistinctOperation } from '../operations/findDistinct.js'
export const findDistinctHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)
const { depth, field, limit, page, sort, where } = req.query as {
const { depth, field, limit, page, sort, trash, where } = req.query as {
depth?: string
field?: string
limit?: string
page?: string
sort?: string
sortOrder?: string
trash?: string
where?: Where
}
@@ -33,6 +34,7 @@ export const findDistinctHandler: PayloadHandler = async (req) => {
page: isNumber(page) ? Number(page) : undefined,
req,
sort: typeof sort === 'string' ? sort.split(',') : undefined,
trash: trash === 'true',
where,
})

View File

@@ -9,7 +9,7 @@ import { docAccessHandler } from './docAccess.js'
import { duplicateHandler } from './duplicate.js'
import { findHandler } from './find.js'
import { findByIDHandler } from './findByID.js'
import { findDistinctHandler } from './findDistinct.js'
// import { findDistinctHandler } from './findDistinct.js'
import { findVersionByIDHandler } from './findVersionByID.js'
import { findVersionsHandler } from './findVersions.js'
import { previewHandler } from './preview.js'

View File

@@ -12,6 +12,7 @@ import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js'
import { APIError } from '../../errors/APIError.js'
import { Forbidden } from '../../errors/Forbidden.js'
import { relationshipPopulationPromise } from '../../fields/hooks/afterRead/relationshipPopulationPromise.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { buildAfterOperation } from './utils.js'
@@ -29,6 +30,7 @@ export type Arguments = {
req?: PayloadRequest
showHiddenFields?: boolean
sort?: Sort
trash?: boolean
where?: Where
}
export const findDistinctOperation = async (
@@ -60,6 +62,7 @@ export const findDistinctOperation = async (
overrideAccess,
populate,
showHiddenFields = false,
trash = false,
where,
} = args
@@ -96,9 +99,16 @@ export const findDistinctOperation = async (
// Find Distinct
// /////////////////////////////////////
const fullWhere = combineQueries(where!, accessResult!)
let fullWhere = combineQueries(where!, accessResult!)
sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere })
// Exclude trashed documents when trash: false
fullWhere = appendNonTrashedFilter({
enableTrash: collectionConfig.trash,
trash,
where: fullWhere,
})
await validateQueryPaths({
collectionConfig,
overrideAccess: overrideAccess!,

View File

@@ -83,6 +83,15 @@ export type Options<
* @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt
*/
sort?: Sort
/**
* When set to `true`, the query will include both normal and trashed documents.
* To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`.
* By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field.
*
* This argument has no effect unless `trash` is enabled on the collection.
* @default false
*/
trash?: boolean
/**
* If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks.
*/
@@ -111,6 +120,7 @@ export async function findDistinct<
populate,
showHiddenFields,
sort,
trash = false,
where,
} = options
const collection = payload.collections[collectionSlug]
@@ -133,6 +143,7 @@ export async function findDistinct<
req: await createLocalReq(options as CreateLocalReqOptions, payload),
showHiddenFields,
sort,
trash,
where,
}) as Promise<PaginatedDistinctDocs<Record<TField, DataFromCollectionSlug<TSlug>[TField]>>>
}

View File

@@ -13,6 +13,7 @@ export const PostsCollection: CollectionConfig = {
groupBy: true,
defaultColumns: ['title', 'category', 'createdAt', 'updatedAt'],
},
trash: true,
fields: [
{
name: 'title',

View File

@@ -38,6 +38,7 @@ test.describe('Group By', () => {
let serverURL: string
let payload: PayloadTestSDK<Config>
let user: any
let category1Id: number | string
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -56,6 +57,17 @@ test.describe('Group By', () => {
password: devUser.password,
},
})
// Fetch category IDs from already-seeded data
const categories = await payload.find({
collection: 'categories',
limit: 1,
sort: 'title',
where: { title: { equals: 'Category 1' } },
})
const [category1] = categories.docs
category1Id = category1?.id as number | string
})
beforeEach(async () => {
@@ -604,4 +616,38 @@ test.describe('Group By', () => {
}),
).toHaveCount(0)
})
test('should show trashed docs in trash view when group-by is active', async () => {
await page.goto(url.list)
// Enable group-by on Category
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(2) // We expect 2 groups initially
// Trash the first document in the first group
const firstTable = page.locator('.table-wrap').first()
await firstTable.locator('.row-1 .cell-_select input').check()
await firstTable.locator('.list-selection__button[aria-label="Delete"]').click()
const modalId = `[id^="${category1Id}-confirm-delete-many-docs"]`
// Confirm trash (skip permanent delete)
await page.locator(`${modalId} #confirm-action`).click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Go to the trash view
await page.locator('#trash-view-pill').click()
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
// Re-enable group-by on Category in trash view
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(1) // Should only have Category 1 (or the trashed doc's category)
// Ensure the trashed doc is visible
await expect(
page.locator('.table-wrap tbody tr td.cell-title', { hasText: 'Find me' }),
).toBeVisible()
})
})

View File

@@ -143,6 +143,7 @@ export interface Post {
tab1Field?: string | null;
updatedAt: string;
createdAt: string;
deletedAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -298,6 +299,7 @@ export interface PostsSelect<T extends boolean = true> {
tab1Field?: T;
updatedAt?: T;
createdAt?: T;
deletedAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -1,10 +1,5 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { devUser } from '../credentials.js'
import { executePromises } from '../helpers/executePromises.js'
import { seedDB } from '../helpers/seed.js'

View File

@@ -1,4 +1,4 @@
import type { Payload } from 'payload'
import type { CollectionSlug, Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -53,7 +53,7 @@ describe('trash', () => {
})
restrictedCollectionDoc = await payload.create({
collection: restrictedCollectionSlug,
collection: restrictedCollectionSlug as CollectionSlug,
data: {
title: 'With Access Control one',
},
@@ -93,7 +93,7 @@ describe('trash', () => {
it('should not allow bulk soft-deleting documents when restricted by delete access', async () => {
await expect(
payload.update({
collection: restrictedCollectionSlug,
collection: restrictedCollectionSlug as CollectionSlug,
data: {
deletedAt: new Date().toISOString(),
},
@@ -116,7 +116,7 @@ describe('trash', () => {
it('should not allow soft-deleting a document when restricted by delete access', async () => {
await expect(
payload.update({
collection: restrictedCollectionSlug,
collection: restrictedCollectionSlug as CollectionSlug,
data: {
deletedAt: new Date().toISOString(),
},
@@ -183,13 +183,78 @@ describe('trash', () => {
trash: false, // Normal query should return it now
})
const restored = result.docs.find((doc) => doc.id === postsDocTwo.id)
const restored = result.docs.find(
(doc) => (doc.id as number | string) === (postsDocTwo.id as number | string),
)
expect(restored).toBeDefined()
expect(restored?.deletedAt).toBeNull()
})
})
describe('findDistinct', () => {
it('should return all unique values for a field (excluding soft-deleted docs by default)', async () => {
// Add a duplicate title
await payload.create({
collection: postsSlug,
data: { title: 'Doc one' },
})
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
})
const titles = result.values.map((v) => v.title)
// Expect only distinct titles of non-trashed docs
expect(titles).toContain('Doc one')
expect(titles).not.toContain('Doc two') // because it's soft-deleted
expect(titles).toHaveLength(1)
})
it('should include soft-deleted docs when trash: true', async () => {
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
trash: true,
})
const titles = result.values.map((v) => v.title)
expect(titles).toContain('Doc one')
expect(titles).toContain('Doc two') // soft-deleted doc
})
it('should return only distinct values from soft-deleted docs when where[deletedAt][exists]=true', async () => {
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
trash: true,
where: {
deletedAt: { exists: true },
},
})
const titles = result.values.map((v) => v.title)
expect(titles).toEqual(['Doc two']) // Only the soft-deleted doc
})
it('should respect where filters when retrieving distinct values', async () => {
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
trash: true,
where: {
title: { equals: 'Doc two' },
},
})
const titles = result.values.map((v) => v.title)
expect(titles).toEqual(['Doc two'])
})
})
describe('findByID operation', () => {
it('should return a soft-deleted document when trash: true', async () => {
const trashedPostDoc: Post = await payload.findByID({