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:
@@ -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 || {}),
|
||||
|
||||
@@ -205,6 +205,7 @@ export const renderListView = async (
|
||||
enableRowSelections,
|
||||
query,
|
||||
req,
|
||||
trash: query?.trash === true,
|
||||
user,
|
||||
where: whereWithMergedSearch,
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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]>>>
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PostsCollection: CollectionConfig = {
|
||||
groupBy: true,
|
||||
defaultColumns: ['title', 'category', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
trash: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user