diff --git a/packages/next/src/views/List/handleGroupBy.ts b/packages/next/src/views/List/handleGroupBy.ts index 8d96e5d7e8..f5f9700af9 100644 --- a/packages/next/src/views/List/handleGroupBy.ts +++ b/packages/next/src/views/List/handleGroupBy.ts @@ -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 || {}), diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index db514c5884..81ecb435ad 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -205,6 +205,7 @@ export const renderListView = async ( enableRowSelections, query, req, + trash: query?.trash === true, user, where: whereWithMergedSearch, })) diff --git a/packages/payload/src/collections/endpoints/findDistinct.ts b/packages/payload/src/collections/endpoints/findDistinct.ts index 3a7eb4b927..3e5a3d1606 100644 --- a/packages/payload/src/collections/endpoints/findDistinct.ts +++ b/packages/payload/src/collections/endpoints/findDistinct.ts @@ -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, }) diff --git a/packages/payload/src/collections/endpoints/index.ts b/packages/payload/src/collections/endpoints/index.ts index 368cd58eb6..b522170b6a 100644 --- a/packages/payload/src/collections/endpoints/index.ts +++ b/packages/payload/src/collections/endpoints/index.ts @@ -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' diff --git a/packages/payload/src/collections/operations/findDistinct.ts b/packages/payload/src/collections/operations/findDistinct.ts index 2c814f5f4f..fd7574866a 100644 --- a/packages/payload/src/collections/operations/findDistinct.ts +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -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!, diff --git a/packages/payload/src/collections/operations/local/findDistinct.ts b/packages/payload/src/collections/operations/local/findDistinct.ts index 2a0ca5cd73..d0443fb437 100644 --- a/packages/payload/src/collections/operations/local/findDistinct.ts +++ b/packages/payload/src/collections/operations/local/findDistinct.ts @@ -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[TField]>>> } diff --git a/test/group-by/collections/Posts/index.ts b/test/group-by/collections/Posts/index.ts index 9244714afb..f1ca9854c7 100644 --- a/test/group-by/collections/Posts/index.ts +++ b/test/group-by/collections/Posts/index.ts @@ -13,6 +13,7 @@ export const PostsCollection: CollectionConfig = { groupBy: true, defaultColumns: ['title', 'category', 'createdAt', 'updatedAt'], }, + trash: true, fields: [ { name: 'title', diff --git a/test/group-by/e2e.spec.ts b/test/group-by/e2e.spec.ts index 84ec87f6cb..201c5d0662 100644 --- a/test/group-by/e2e.spec.ts +++ b/test/group-by/e2e.spec.ts @@ -38,6 +38,7 @@ test.describe('Group By', () => { let serverURL: string let payload: PayloadTestSDK 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() + }) }) diff --git a/test/group-by/payload-types.ts b/test/group-by/payload-types.ts index 4f275f797b..271ec36b2b 100644 --- a/test/group-by/payload-types.ts +++ b/test/group-by/payload-types.ts @@ -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 { tab1Field?: T; updatedAt?: T; createdAt?: T; + deletedAt?: T; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/test/group-by/seed.ts b/test/group-by/seed.ts index 45ae510ef4..4a0436c09a 100644 --- a/test/group-by/seed.ts +++ b/test/group-by/seed.ts @@ -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' diff --git a/test/trash/int.spec.ts b/test/trash/int.spec.ts index 573dbf3945..b739245e25 100644 --- a/test/trash/int.spec.ts +++ b/test/trash/int.spec.ts @@ -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({