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,
|
enableRowSelections,
|
||||||
query,
|
query,
|
||||||
req,
|
req,
|
||||||
|
trash = false,
|
||||||
user,
|
user,
|
||||||
where: whereWithMergedSearch,
|
where: whereWithMergedSearch,
|
||||||
}: {
|
}: {
|
||||||
@@ -34,6 +35,7 @@ export const handleGroupBy = async ({
|
|||||||
enableRowSelections?: boolean
|
enableRowSelections?: boolean
|
||||||
query?: ListQuery
|
query?: ListQuery
|
||||||
req: PayloadRequest
|
req: PayloadRequest
|
||||||
|
trash?: boolean
|
||||||
user: any
|
user: any
|
||||||
where: Where
|
where: Where
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -88,6 +90,7 @@ export const handleGroupBy = async ({
|
|||||||
populate,
|
populate,
|
||||||
req,
|
req,
|
||||||
sort: query?.groupBy,
|
sort: query?.groupBy,
|
||||||
|
trash,
|
||||||
where: whereWithMergedSearch,
|
where: whereWithMergedSearch,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,6 +130,7 @@ export const handleGroupBy = async ({
|
|||||||
// Note: if we wanted to enable table-by-table sorting, we could use this:
|
// Note: if we wanted to enable table-by-table sorting, we could use this:
|
||||||
// sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort,
|
// sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort,
|
||||||
sort: query?.sort,
|
sort: query?.sort,
|
||||||
|
trash,
|
||||||
user,
|
user,
|
||||||
where: {
|
where: {
|
||||||
...(whereWithMergedSearch || {}),
|
...(whereWithMergedSearch || {}),
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export const renderListView = async (
|
|||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
query,
|
query,
|
||||||
req,
|
req,
|
||||||
|
trash: query?.trash === true,
|
||||||
user,
|
user,
|
||||||
where: whereWithMergedSearch,
|
where: whereWithMergedSearch,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import { findDistinctOperation } from '../operations/findDistinct.js'
|
|||||||
|
|
||||||
export const findDistinctHandler: PayloadHandler = async (req) => {
|
export const findDistinctHandler: PayloadHandler = async (req) => {
|
||||||
const collection = getRequestCollection(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
|
depth?: string
|
||||||
field?: string
|
field?: string
|
||||||
limit?: string
|
limit?: string
|
||||||
page?: string
|
page?: string
|
||||||
sort?: string
|
sort?: string
|
||||||
sortOrder?: string
|
sortOrder?: string
|
||||||
|
trash?: string
|
||||||
where?: Where
|
where?: Where
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export const findDistinctHandler: PayloadHandler = async (req) => {
|
|||||||
page: isNumber(page) ? Number(page) : undefined,
|
page: isNumber(page) ? Number(page) : undefined,
|
||||||
req,
|
req,
|
||||||
sort: typeof sort === 'string' ? sort.split(',') : undefined,
|
sort: typeof sort === 'string' ? sort.split(',') : undefined,
|
||||||
|
trash: trash === 'true',
|
||||||
where,
|
where,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { docAccessHandler } from './docAccess.js'
|
|||||||
import { duplicateHandler } from './duplicate.js'
|
import { duplicateHandler } from './duplicate.js'
|
||||||
import { findHandler } from './find.js'
|
import { findHandler } from './find.js'
|
||||||
import { findByIDHandler } from './findByID.js'
|
import { findByIDHandler } from './findByID.js'
|
||||||
import { findDistinctHandler } from './findDistinct.js'
|
// import { findDistinctHandler } from './findDistinct.js'
|
||||||
import { findVersionByIDHandler } from './findVersionByID.js'
|
import { findVersionByIDHandler } from './findVersionByID.js'
|
||||||
import { findVersionsHandler } from './findVersions.js'
|
import { findVersionsHandler } from './findVersions.js'
|
||||||
import { previewHandler } from './preview.js'
|
import { previewHandler } from './preview.js'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js'
|
|||||||
import { APIError } from '../../errors/APIError.js'
|
import { APIError } from '../../errors/APIError.js'
|
||||||
import { Forbidden } from '../../errors/Forbidden.js'
|
import { Forbidden } from '../../errors/Forbidden.js'
|
||||||
import { relationshipPopulationPromise } from '../../fields/hooks/afterRead/relationshipPopulationPromise.js'
|
import { relationshipPopulationPromise } from '../../fields/hooks/afterRead/relationshipPopulationPromise.js'
|
||||||
|
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
|
||||||
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
|
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
|
||||||
import { killTransaction } from '../../utilities/killTransaction.js'
|
import { killTransaction } from '../../utilities/killTransaction.js'
|
||||||
import { buildAfterOperation } from './utils.js'
|
import { buildAfterOperation } from './utils.js'
|
||||||
@@ -29,6 +30,7 @@ export type Arguments = {
|
|||||||
req?: PayloadRequest
|
req?: PayloadRequest
|
||||||
showHiddenFields?: boolean
|
showHiddenFields?: boolean
|
||||||
sort?: Sort
|
sort?: Sort
|
||||||
|
trash?: boolean
|
||||||
where?: Where
|
where?: Where
|
||||||
}
|
}
|
||||||
export const findDistinctOperation = async (
|
export const findDistinctOperation = async (
|
||||||
@@ -60,6 +62,7 @@ export const findDistinctOperation = async (
|
|||||||
overrideAccess,
|
overrideAccess,
|
||||||
populate,
|
populate,
|
||||||
showHiddenFields = false,
|
showHiddenFields = false,
|
||||||
|
trash = false,
|
||||||
where,
|
where,
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
@@ -96,9 +99,16 @@ export const findDistinctOperation = async (
|
|||||||
// Find Distinct
|
// Find Distinct
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
|
|
||||||
const fullWhere = combineQueries(where!, accessResult!)
|
let fullWhere = combineQueries(where!, accessResult!)
|
||||||
sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere })
|
sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere })
|
||||||
|
|
||||||
|
// Exclude trashed documents when trash: false
|
||||||
|
fullWhere = appendNonTrashedFilter({
|
||||||
|
enableTrash: collectionConfig.trash,
|
||||||
|
trash,
|
||||||
|
where: fullWhere,
|
||||||
|
})
|
||||||
|
|
||||||
await validateQueryPaths({
|
await validateQueryPaths({
|
||||||
collectionConfig,
|
collectionConfig,
|
||||||
overrideAccess: overrideAccess!,
|
overrideAccess: overrideAccess!,
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ export type Options<
|
|||||||
* @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt
|
* @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt
|
||||||
*/
|
*/
|
||||||
sort?: Sort
|
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.
|
* 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,
|
populate,
|
||||||
showHiddenFields,
|
showHiddenFields,
|
||||||
sort,
|
sort,
|
||||||
|
trash = false,
|
||||||
where,
|
where,
|
||||||
} = options
|
} = options
|
||||||
const collection = payload.collections[collectionSlug]
|
const collection = payload.collections[collectionSlug]
|
||||||
@@ -133,6 +143,7 @@ export async function findDistinct<
|
|||||||
req: await createLocalReq(options as CreateLocalReqOptions, payload),
|
req: await createLocalReq(options as CreateLocalReqOptions, payload),
|
||||||
showHiddenFields,
|
showHiddenFields,
|
||||||
sort,
|
sort,
|
||||||
|
trash,
|
||||||
where,
|
where,
|
||||||
}) as Promise<PaginatedDistinctDocs<Record<TField, DataFromCollectionSlug<TSlug>[TField]>>>
|
}) as Promise<PaginatedDistinctDocs<Record<TField, DataFromCollectionSlug<TSlug>[TField]>>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const PostsCollection: CollectionConfig = {
|
|||||||
groupBy: true,
|
groupBy: true,
|
||||||
defaultColumns: ['title', 'category', 'createdAt', 'updatedAt'],
|
defaultColumns: ['title', 'category', 'createdAt', 'updatedAt'],
|
||||||
},
|
},
|
||||||
|
trash: true,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ test.describe('Group By', () => {
|
|||||||
let serverURL: string
|
let serverURL: string
|
||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
let user: any
|
let user: any
|
||||||
|
let category1Id: number | string
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }, testInfo) => {
|
test.beforeAll(async ({ browser }, testInfo) => {
|
||||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||||
@@ -56,6 +57,17 @@ test.describe('Group By', () => {
|
|||||||
password: devUser.password,
|
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 () => {
|
beforeEach(async () => {
|
||||||
@@ -604,4 +616,38 @@ test.describe('Group By', () => {
|
|||||||
}),
|
}),
|
||||||
).toHaveCount(0)
|
).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;
|
tab1Field?: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
deletedAt?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
@@ -298,6 +299,7 @@ export interface PostsSelect<T extends boolean = true> {
|
|||||||
tab1Field?: T;
|
tab1Field?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
|
deletedAt?: T;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import type { Payload } from 'payload'
|
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 { devUser } from '../credentials.js'
|
||||||
import { executePromises } from '../helpers/executePromises.js'
|
import { executePromises } from '../helpers/executePromises.js'
|
||||||
import { seedDB } from '../helpers/seed.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 path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -53,7 +53,7 @@ describe('trash', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
restrictedCollectionDoc = await payload.create({
|
restrictedCollectionDoc = await payload.create({
|
||||||
collection: restrictedCollectionSlug,
|
collection: restrictedCollectionSlug as CollectionSlug,
|
||||||
data: {
|
data: {
|
||||||
title: 'With Access Control one',
|
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 () => {
|
it('should not allow bulk soft-deleting documents when restricted by delete access', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
payload.update({
|
payload.update({
|
||||||
collection: restrictedCollectionSlug,
|
collection: restrictedCollectionSlug as CollectionSlug,
|
||||||
data: {
|
data: {
|
||||||
deletedAt: new Date().toISOString(),
|
deletedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
@@ -116,7 +116,7 @@ describe('trash', () => {
|
|||||||
it('should not allow soft-deleting a document when restricted by delete access', async () => {
|
it('should not allow soft-deleting a document when restricted by delete access', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
payload.update({
|
payload.update({
|
||||||
collection: restrictedCollectionSlug,
|
collection: restrictedCollectionSlug as CollectionSlug,
|
||||||
data: {
|
data: {
|
||||||
deletedAt: new Date().toISOString(),
|
deletedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
@@ -183,13 +183,78 @@ describe('trash', () => {
|
|||||||
trash: false, // Normal query should return it now
|
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).toBeDefined()
|
||||||
expect(restored?.deletedAt).toBeNull()
|
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', () => {
|
describe('findByID operation', () => {
|
||||||
it('should return a soft-deleted document when trash: true', async () => {
|
it('should return a soft-deleted document when trash: true', async () => {
|
||||||
const trashedPostDoc: Post = await payload.findByID({
|
const trashedPostDoc: Post = await payload.findByID({
|
||||||
|
|||||||
Reference in New Issue
Block a user