Compare commits

..

52 Commits

Author SHA1 Message Date
Jarrod Flesch
c9d0d1ffcc clean up provider a bit 2025-07-30 21:52:48 -04:00
Jarrod Flesch
9031f3bf23 feat: add hooks to restoreVersion collection operation (#13333)
Adds missing hooks to the restoreVersion operation.
- beforeOperation
- beforeValidate - Fields
- beforeValidate - Collection
- beforeChange - Collection
- beforeChange - Fields
- afterOperation
2025-07-30 21:25:53 -04:00
Muhammad Nizar
df91321f4a feat(translations): add Indonesian translations (#13323)
### What?
Translated payload to Indonesian

### Why?
I needed to have payload in Indonesian

### How?
By following the
[readme](https://github.com/payloadcms/payload/blob/main/packages/translations/README.md)
2025-07-30 21:35:22 +00:00
Patrik
11755089f8 feat: adds trash support to the count operation (#13304)
### What?

- Updated the `countOperation` to respect the `trash` argument.

### Why?

- Previously, `count` would incorrectly include trashed documents even
when `trash` was not specified.
- This change aligns `count` behavior with `find` and other operations,
providing accurate counts for normal and trashed documents.

### How?

- Applied `appendNonTrashedFilter` in `countOperation` to automatically
exclude soft-deleted docs when `trash: false` (default).
- Added `trash` argument support in Local API, REST API (`/count`
endpoints), and GraphQL (`count<Collection>` queries).
2025-07-30 14:11:11 -07:00
Patrik
a8b6983ab5 test: adds e2e tests for auth enabled collections with trash enabled (#13317)
### What?
- Added new end-to-end tests covering trash functionality for
auth-enabled collections (e.g., `users`).
- Implemented test cases for:
  - Display of the trash tab in the list view.
  - Trashing a user and verifying its appearance in the trash view.
  - Accessing the trashed user edit view.
  - Ensuring all auth fields are properly disabled in trashed state.
  - Restoring a trashed user and verifying its status.

### Why?
- To ensure that the trash (soft-delete) feature works consistently for
collections with `auth: true`.
- To prevent regressions in user management flows, especially around
disabling and restoring trashed users.

### How?
- Added a new `Auth enabled collection` test suite in the E2E `Trash`
tests.
2025-07-30 14:11:02 -07:00
Jarrod Flesch
f2d4004237 fix(ui): incorrect padding when using rtl (#13338)
Fixes inconsistent padding in the doc view for RTL viewing.

###  Before

#### Desktop
<img width="1331" height="310" alt="CleanShot 2025-07-30 at 16 37 30"
src="https://github.com/user-attachments/assets/48d3e127-09dd-4356-99ae-16fe47817937"
/>

#### Mobile
<img width="619" height="328" alt="CleanShot 2025-07-30 at 16 37 52"
src="https://github.com/user-attachments/assets/36169ca5-c1a2-4175-a6e1-f0a4784d5e9e"
/>


###  After

#### Desktop
<img width="1675" height="291" alt="CleanShot 2025-07-30 at 16 39 18"
src="https://github.com/user-attachments/assets/1da78a8a-b236-4f95-9eb2-8b5055b676ae"
/>

#### Mobile
<img width="531" height="309" alt="CleanShot 2025-07-30 at 16 39 30"
src="https://github.com/user-attachments/assets/af858bfc-6d75-4139-ada1-4f8100744bfb"
/>
2025-07-30 21:03:03 +00:00
Alessio Gravili
8a489410ad fix(next): incorrect version view stepnav value (#13305)
Previously, the version view stepnav incorrectly displayed the version
ID instead of the parent document ID in the navigation. It also
incorrectly pulled the field value if `useAsTitle` was set, displayed
the `[undefined]` instead.

**Edit View:**
<img width="2218" height="244" alt="Screenshot 2025-07-28 at 16 55
26@2x"
src="https://github.com/user-attachments/assets/7b144480-ec5a-4592-b603-09e1e35bd558"
/>

## Version View - Before:

<img width="2280" height="378" alt="Screenshot 2025-07-28 at 16 56
02@2x"
src="https://github.com/user-attachments/assets/8b79bab3-a79b-4930-ade1-2da450f00fc7"
/>


## Version View - After:

**Version View:**
<img width="2222" height="358" alt="Screenshot 2025-07-28 at 16 55
46@2x"
src="https://github.com/user-attachments/assets/bb0fffbb-c3e6-419f-ad72-5731e85059cc"
/>


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210907527783756
2025-07-30 20:59:48 +00:00
Alessio Gravili
095e7d904f fix(next): safely access relationTo value from relationship field (#13337)
Fixes https://github.com/payloadcms/payload/issues/13330
2025-07-30 20:54:04 +00:00
Patrik
c48b57fdbf fix(next): incorrect doc link in trash view with groupBy enabled (#13332)
### What?

Fixes an issue where document links in the trash view were incorrectly
generated when group-by was enabled for a collection. Previously, links
in grouped tables would omit the `/trash` segment, causing navigation to
crash while trying to route to the default edit view instead of the
trashed document view.

### Why?

When viewing a collection in group-by mode, document rows are rendered
in grouped tables via the `handleGroupBy` logic. However, these tables
were unaware of whether the view was operating in trash mode, so the
generated row links did not include the necessary `/trash` segment. This
broke navigation when trying to view or edit trashed documents.

### How?

- Threaded the `viewType` prop through `renderListView` into the
`handleGroupBy` utility.
- Passed `viewType` into each `renderTable` call within `handleGroupBy`,
ensuring proper link generation.
- `renderTable` already supports `viewType` and appends `/trash` to edit
links when it's set to 'trash'.
2025-07-30 13:17:03 -07:00
Sasha
b26a73be4a fix: querying hasMany: true select fields inside polymorphic joins (#13334)
This PR fixes queries like this:

```ts
const findFolder = await payload.find({
  collection: 'payload-folders',
  where: {
    id: {
      equals: folderDoc.id,
    },
  },
  joins: {
    documentsAndFolders: {
      limit: 100_000,
      sort: 'name',
      where: {
        and: [
          {
            relationTo: {
              equals: 'payload-folders',
            },
          },
          {
            folderType: {
              in: ['folderPoly1'], // previously this didn't work
            },
          },
        ],
      },
    },
  },
})
```

Additionally, this PR potentially fixes querying JSON fields by the top
level path, for example if your JSON field has a value like: `[1, 2]`,
previously `where: { json: { equals: 1 } }` didn't work, however with a
value like `{ nested: [1, 2] }` and a query `where: { 'json.nested': {
equals: 1 } }`it did.

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-07-30 15:30:20 -04:00
Alessio Gravili
3114b89d4c perf: 23% faster job queue system on postgres/sqlite (#13187)
Previously, a single run of the simplest job queue workflow (1 single
task, no db calls by user code in the task - we're just testing db
system overhead) would result in **22 db roundtrips** on drizzle. This
PR reduces it to **17 db roundtrips** by doing the following:

- Modifies db.updateJobs to use the new optimized upsertRow function if
the update is simple
- Do not unnecessarily pass the job log to the final job update when the
workflow completes => allows using the optimized upsertRow function, as
only the main table is involved

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210888186878606
2025-07-30 16:23:43 +03:00
Evelyn Hathaway
227a20e94b fix(richtext-lexical): recursively unwrap generic Slate nodes in Lexical migration converter (#13202)
## What?

The Slate to Lexical migration script assumes that the depth of Slate
nodes matches the depth of the Lexical schema, which isn't necessarily
true. This pull request fixes this assumption by first checking for
children and unwrapping the text nodes.

## Why?

During my migration, I ran into a lot of copy + pasted rich text with
list items with untyped nodes with `children`. The existing migration
script assumed that since list items can't have paragraphs, all untyped
nodes inside must be text nodes.

The result of the migration script was a lot of invalid text nodes with
`text: undefined` and all of the content in the `children` being
silently lost. Beyond the silent loss, the invalid text nodes caused the
Lexical editor to unmount with an error about accessing `0 of
undefined`, so those documents couldn't be edited.

This additionally makes the migration script more closely align with the
[recursive serialization logic recommendation from the Payload Slate
Rich Text
documentation](https://payloadcms.com/docs/rich-text/slate#generating-html).

## Visualization

### Slate

```txt
Slate rich text content
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┗━┳━ Generic (paragraph-like, untyped with children)
┋ ┋   ┣━━━ Text (untyped) `Hello `
┋ ┋   ┗━━━ Text (untyped) `World!
[...]
```

### Lexical Before PR

```txt
Lexical rich text content (invalid)
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┗━━━ Invalid text (assumed the generic node was text, stopped processing children, cannot restore lost text without a restoring backup with Slate and rerunning the script after this MR)
[...]
```

### Lexical After PR

```txt
Lexical rich text content
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┣━━━ Text `Hello `
┋ ┋ ┗━━━ Text `World!
[...]
```

---------

Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
2025-07-30 13:16:18 +00:00
Jarrod Flesch
a22f27de1c test: stabilize frequent fails (#13318)
Adjusts tests that "flake" frequently.
2025-07-30 05:52:01 -07:00
Patrik
e7124f6176 fix(next): cannot filter trash (#13320)
### What?

- Updated `TrashView` to pass `trash: true` as a dedicated prop instead
of embedding it in the `query` object.
- Modified `renderListView` to correctly merge `trash` and `where`
queries by using both `queryFromArgs` and `queryFromReq`.
- Ensured filtering (via `where`) works correctly in the trash view.

### Why?

Previously, the `trash: true` flag was injected into the `query` object,
and `renderListView` only used `queryFromArgs`.
This caused the `where` clause from filters (added by the
`WhereBuilder`) to be overridden, breaking filtering in the trash view.

### How?

- Introduced an explicit `trash` prop in `renderListView` arguments.
- Updated `TrashView` to pass `trash: true` separately.
- Updated `renderListView` to apply the `trash` filter in addition to
any `where` conditions.
2025-07-29 14:29:04 -07:00
Elliot DeNolf
183f313387 chore(release): v3.49.1 [skip ci] 2025-07-29 16:38:50 -04:00
contip
b1fa76e397 fix: keep apiKey encrypted in refresh operation (#13063) (#13177)
### What?
Prevents decrypted apiKey from being saved back to database on the auth
refresh operation.

### Why?
References issue #13063: refreshing a token for a logged-in user
decrypted `apiKey` and wrote it back in plaintext, corrupting the user
record.

### How?
The user is now fetched with `db.findOne` instead of `findByID`,
preserving the encryption of the key when saved back to the database
using `db.updateOne`. The user record is then re-fetched using
`findByID`, allowing for the decrypted key to be provided in the
response.

### Tests
*  keeps apiKey encrypted in DB after refresh
*  returns user with decrypted apiKey after refresh

Fixes #13063
2025-07-29 16:27:45 -04:00
German Jablonski
08942494e3 fix: filters cookies with the payload- prefix in getExternalFile by default (#13215)
### What

- filters cookies with the `payload-` prefix in `getExternalFile` by
default (if `externalFileHeaderFilter` is not used).
- Document in `externalFileHeaderFilter`, that the user should handle
the removing of the payload cookie.

### Why

In the Payload application, the `getExternalFile` function sends the
user's cookies to an external server when fetching media, inadvertently
exposing the user's session to that third-party service.




```ts
const headers = uploadConfig.externalFileHeaderFilter
  ? uploadConfig.externalFileHeaderFilter(Object.fromEntries(new Headers(req.headers)))
  : { cookie: req.headers?.get('cookie') };

const res = await fetch(fileURL, {
  credentials: 'include',
  headers,
  method: 'GET',
});
```
Although the
[externalFileHeaderFilter](https://payloadcms.com/docs/upload/overview#collection-upload-options)
function can strip sensitive cookies from the request, the default
config includes the session cookie, violating the secure-by-default
principle.

### How

- If `externalFileHeaderFilter` is not defined, any cookie beginning
with `payload-` is filtered.
- Added 2 tests: both for the case where `externalFileHeaderFilter` is
defined and for the case where it is not.





---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210561338171125
2025-07-29 16:21:50 -04:00
Adebayo Ajayi
da8bf69054 fix(translations): improve swedish translations (#13272)
### What?
Update Swedish translation, removing minor inconsistencies and opting
for more natural sounding translations
### Why?
The current Swedish translation contained some minor grammatical issues
and inconsistencies that make the UI feel less natural to Swedish users.
### How?
- Fixed "e-post" hyphenation consistency
- Changed "Alla platser" → "Alla språk" (locales should be "languages")
- Improved action verbs: "Tydlig" → "Rensa", "Stänga" → "Stäng"
- Made "Kollapsa" → "Fäll ihop" more natural
- Standardized preview terminology: "Live förhandsgranskning" →
"förhandsgranskning"
- Fixed terminology: "fältdatabas" → "fältdata" (fältdatabas mean field
database while fältdata means field data)
- Changed "Programinställningar" → "Systeminställningar" (more
appropriate for software)
- Fixed punctuation: em dash → comma in "sorryNotFound"
- Improved "Visa endast läsning" → "Visa som skrivskyddad"
(grammatically correct)

Fixes #
2025-07-29 18:37:12 +00:00
brutumfulmen97
26d9daeccf fix(translations): missing closing brace in rs latin translation for lastSavedAgo (#13172)
Fixed missing closing brace in the translations package for the language
rs latin.
<img width="885" height="200" alt="image"
src="https://github.com/user-attachments/assets/12d00305-6cc9-46ce-87e8-2c66f9d9e63c"
/>
Here is the code diff.

Co-authored-by: Vlatko Percic <vlatko@studiopresent.com>
2025-07-29 18:20:29 +00:00
Alejandro Martinez
fc5944840e docs: remove asterisk from optional url property in live preview docs (#13108)
### What?

url option is not required for Link Preview, so I removed the asterisk.

### Why?

Here is the type definition

[Type Definition
Link](c6105f1e0d/packages/payload/src/config/types.ts (L159))
2025-07-29 14:16:19 -04:00
Aayush Rajagopalan
9e04dbb1ca docs: typo in vercel-content-link.mdx (#13170)
### Fixes:
'title,' -> 'title',
2025-07-29 18:07:15 +00:00
Sean Zubrickas
72954ce9f2 docs: fixes typo in ternary operator for live preview docs (#13163)
Fixes ternary operator in live preview docs.
2025-07-29 13:56:31 -04:00
Jacob Fletcher
e50220374e fix(next): group by null relationship crashes list view (#13315)
When grouping by a relationship field and it's value is `null`, the list
view crashes.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210916642997992
2025-07-29 11:55:02 -04:00
Jacob Fletcher
61ee8fadca fix(ui): autosave-enabled document drawers close unexpectedly within the join field (#13298)
Fixes #12975.

When editing autosave-enabled documents through the join field, the
document drawer closes unexpectedly on every autosave interval, making
it nearly impossible to use.

This is because as of #12842, the underlying relationship table
re-renders on every autosave event, remounting the drawer each time. The
fix is to lift the drawer out of table's rendering tree and into the
join field itself. This way all rows share the same drawer, whose
rendering lifecycle has been completely decoupled from the table's
state.

Note: this is very similar to how relationship fields achieve similar
functionality.

This PR also adds jsdocs to the `useDocumentDrawer` hook and strengthens
its types.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210906078627353
2025-07-29 11:49:15 -04:00
Jarrod Flesch
8d84352ee9 fix(next): catch list filter errors, prevent list view crash (#13297)
Catches list filter errors and prevents the list view from crashing when
attempting to search on fields the user does not have access to. Instead
just shows the default "no results found" message.
2025-07-29 11:30:07 -04:00
Elliot DeNolf
4beb27b9ad ci: show path value in audit-dependencies script [skip ci] (#13314)
Improve audit-dependencies script to show the vulnerable package path:

```diff
   {
     "package": "form-data",
     "vulnerable": "<2.5.4",
-    "fixed_in": ">=2.5.4"
+    "fixed_in": ">=2.5.4",
+    "findings": [
+      {
+        "version": "2.5.2",
+        "paths": [
+          "packages/storage-gcs > @google-cloud/storage@7.14.0 > retry-request@7.0.2 > @types/request@2.48.12 > form-data@2.5.2"
+        ]
+      }
+    ]
   }
 ]
```
2025-07-29 11:08:39 -04:00
Jacob Fletcher
c5c8c13057 fix(next): pass req through document tab conditions and custom server components (#13302)
Custom document tab components (server components) do not receive the
`user` prop, as the types suggest. This makes it difficult to wire up
conditional rendering based on the user. This is because tab conditions
don't receive a user argument either, forcing you to render the default
tab component yourself—but a custom component should not be needed for
this in the first place.

Now they both receive `req` alongside `user`, which is more closely
aligned with custom field components.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210906078627357
2025-07-28 23:35:38 -04:00
Jacob Fletcher
a888d5cc53 chore(ui): var name typo in relationship field (#13295)
Fixes typo in variable name within the relationship field component.

`disableFormModication` → `disableFormModification`
2025-07-28 23:34:32 -04:00
Jarrod Flesch
72349245ca test: fix flaky sorting test (#13303)
Ensures the browser uses fresh data after seeding by refreshing the
route and navigating when done.
2025-07-29 03:25:09 +00:00
Alessio Gravili
4fde0f23ce fix: use atomic operation for incrementing login attempts (#13204)
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210561338171141
2025-07-28 16:08:10 -07:00
Patrik
aff2ce1b9b 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`.
2025-07-28 11:29:04 -07:00
Alessio Gravili
5c94d2dc71 feat: support next.js 15.4.4 (#13280)
- bumps next.js from 15.3.2 to 15.4.4 in monorepo and templates. It's
important to run our tests against the latest Next.js version to
guarantee full compatibility.
- bumps playwright because of peer dependency conflict with next 15.4.4
- bumps react types because why not

https://nextjs.org/blog/next-15-4

As part of this upgrade, the functionality added by
https://github.com/payloadcms/payload/pull/11658 broke. This PR fixes it
by creating a wrapper around `React.isValidElemen`t that works for
Next.js 15.4.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210803039809808
2025-07-28 16:23:43 +00:00
Jarrod Flesch
b1aac19668 chore(next): cleanup unused code (#13292)
Looks like a merge resolution kept unused code. The same condition is
added a couple lines below this removal.
2025-07-28 13:43:51 +00:00
Sean Zubrickas
d093bb1f00 fix: refactors toast error rendering (#13252)
Fixes #13191

- Render a single html element for single error messages
- Preserve ul structure for multiple errors
- Updates tests to check for both cases
2025-07-28 05:59:25 -07:00
Alessio Gravili
2e9ba10fb5 docs: remove obsolete scheduler property (#13278)
That property does not exist and was used in a previous, outdated
implementation of auto scheduling
2025-07-25 16:25:47 -07:00
Alessio Gravili
8518141a5e fix(drizzle): respect join.type config (#13258)
Respects join.type instead of hardcoding leftJoin
2025-07-25 15:46:20 -07:00
Alessio Gravili
6d6c9ebc56 perf(drizzle): 2x faster db.deleteMany (#13255)
Previously, `db.deleteMany` on postgres resulted in 2 roundtrips to the
database (find + delete with ids). This PR passes the where query
directly to the `deleteWhere` function, resulting in only one roundtrip
to the database (delete with where).

If the where query queries other tables (=> joins required), this falls
back to find + delete with ids. However, this is also more optimized
than before, as we now pass `select: { id: true }` to the findMany
query.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210871676349299
2025-07-25 15:46:09 -07:00
German Jablonski
7cd4a8a602 fix(richtext-lexical): unify indent between different converters and make paragraphs and lists match without CSS (#13274)
Previously, the Lexical editor was using px, and the JSX converter was
using rem. #12848 fixed the inconsistency by changing the editor to rem,
but it should have been the other way around, changing the JSX converter
to px.

You can see the latest explanation about why it should be 40px
[here](https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085).
In short, that's the default indentation all browsers use for lists.

This time I'm making sure to leave clear comments everywhere and a test
to avoid another regression.

Here is an image of what the e2e test looks like:

<img width="321" height="678" alt="image"
src="https://github.com/user-attachments/assets/8880c7cb-a954-4487-8377-aee17c06754c"
/>

The first part is the Lexical editor, the second is the JSX converter.

As you can see, the checkbox in JSX looks a little odd because it uses
an input checkbox (as opposed to a pseudo-element in the Lexical
editor). I thought about adding an inline style to move it slightly to
the left, but I found that browsers don't have a standard size for the
checkbox; it varies by browser and device.
That requires a little more thought; I'll address that in a future PR.

Fixes #13130
2025-07-25 22:58:49 +01:00
Jarrod Flesch
bc802846c5 fix: serve svg+xml as svg (#13277)
Based from https://github.com/payloadcms/payload/pull/13276

Fixes https://github.com/payloadcms/payload/issues/7624

If an uploaded image has `.svg` ext, and the mimeType is read as
`application/xml` adjust the mimeType to `image/svg+xml`.

---------

Co-authored-by: Philipp Schneider <47689073+philipp-tailor@users.noreply.github.com>
2025-07-25 21:00:51 +00:00
Jarrod Flesch
e8f6cb5ed1 fix: svg+xml file detection (#13276)
Adds logic for svg+xml file type detection.

---------

Co-authored-by: Philipp Schneider <47689073+philipp-tailor@users.noreply.github.com>
2025-07-25 18:33:53 +00:00
Elliot DeNolf
23bd67515c templates: bump for v3.49.0 (#13273)
🤖 Automated bump of templates for v3.49.0

Triggered by user: @denolfe

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-25 13:39:09 -04:00
Jarrod Flesch
e29d1d98d4 fix(plugin-multi-tenant): prefer assigned tenants for selector population (#13213)
When populating the selector it should populate it with assigned tenants
before fetching all tenants that a user has access to.

You may have "public" tenants and while a user may have _access_ to the
tenant, the selector should show the ones they are assigned to. Users
with full access are the ones that should be able to see the public ones
for editing.
2025-07-25 10:10:26 -04:00
Elliot DeNolf
4ac428d250 chore(release): v3.49.0 [skip ci] 2025-07-25 09:27:41 -04:00
Sasha
75385de01f fix: filtering by polymorphic relationships inside other fields (#13265)
Previously, filtering by a polymorphic relationship inside an array /
group (unless the `name` is `version`) / tab caused `QueryError: The
following path cannot be queried:`.
2025-07-25 09:10:21 -04:00
Patrik
f63dc2a10c feat: adds trash support (soft deletes) (#12656)
### What?

This PR introduces complete trash (soft-delete) support. When a
collection is configured with `trash: true`, documents can now be
soft-deleted and restored via both the API and the admin panel.

```
import type { CollectionConfig } from 'payload'

const Posts: CollectionConfig = {
  slug: 'posts',
  trash: true, // <-- New collection config prop @default false
  fields: [
    {
      name: 'title',
      type: 'text',
    },
    // other fields...
  ],
}
```

### Why

Soft deletes allow developers and admins to safely remove documents
without losing data immediately. This enables workflows like reversible
deletions, trash views, and auditing—while preserving compatibility with
drafts, autosave, and version history.

### How?

#### Backend

- Adds new `trash: true` config option to collections.
- When enabled:
  - A `deletedAt` timestamp is conditionally injected into the schema.
- Soft deletion is performed by setting `deletedAt` instead of removing
the document from the database.
- Extends all relevant API operations (`find`, `findByID`, `update`,
`delete`, `versions`, etc.) to support a new `trash` param:
  - `trash: false` → excludes trashed documents (default)
  - `trash: true` → includes both trashed and non-trashed documents
- To query **only trashed** documents: use `trash: true` with a `where`
clause like `{ deletedAt: { exists: true } }`
- Enforces delete access control before allowing a soft delete via
update or updateByID.
- Disables version restoring on trashed documents (must be restored
first).

#### Admin Panel

- Adds a dedicated **Trash view**: `/collections/:collectionSlug/trash`
- Default delete action now soft-deletes documents when `trash: true` is
set.
- **Delete confirmation modal** includes a checkbox to permanently
delete instead.
- Trashed documents:
- Displays UI banner for better clarity of trashed document edit view vs
non-trashed document edit view
  - Render in a read-only edit view
  - Still allow access to **Preview**, **API**, and **Versions** tabs
- Updated Status component:
- Displays “Previously published” or “Previously a draft” for trashed
documents.
  - Disables status-changing actions when documents are in trash.
- Adds new **Restore** bulk action to clear the `deletedAt` timestamp.
- New `Restore` and `Permanently Delete` buttons for
single-trashed-document restore and permanent deletion.
- **Restore confirmation modal** includes a checkbox to restore as
`published`, defaults to `draft`.
- Adds **Empty Trash** and **Delete permanently** bulk actions.
  
#### Notes

- This feature is completely opt-in. Collections without trash: true
behave exactly as before.



https://github.com/user-attachments/assets/00b83f8a-0442-441e-a89e-d5dc1f49dd37
2025-07-25 09:08:22 -04:00
German Jablonski
4a712b3483 fix(ui): preserve localized blocks and arrays when using CopyToLocale (#13216)
## Problem:
In PR #11887, a bug fix for `copyToLocale` was introduced to address
issues with copying content between locales in Postgres. However, an
incorrect algorithm was used, which removed all "id" properties from
documents being copied. This led to bug #12536, where `copyToLocale`
would mistakenly delete the document in the source language, affecting
not only Postgres but any database.

## Cause and Solution:

When copying documents with localized arrays or blocks, Postgres throws
errors if there are two blocks with the same ID. This is why PR #11887
removed all IDs from the document to avoid conflicts. However, this
removal was too broad and caused issues in cases where it was
unnecessary.


The correct solution should remove the IDs only in nested fields whose
ancestors are localized. The reasoning is as follows:
- When an array/block is **not localized** (`localized: false`), if it
contains localized fields, these fields share the same ID across
different locales.
- When an array/block **is localized** (`localized: true`), its
descendant fields cannot share the same ID across different locales if
Postgres is being used. This wouldn't be an issue if the table
containing localized blocks had a composite primary key of `locale +
id`. However, since the primary key is just `id`, we need to assign a
new ID for these fields.

This PR properly removes IDs **only for nested fields** whose ancestors
are localized.

Fixes #12536

## Example:
### Before Fix:
```js
// Original document (en)
array: [{
  id: "123",
  text: { en: "English text" }
}]

// After copying to 'es' locale, a new ID was created instead of updating the existing item
array: [{
  id: "456",  // 🐛 New ID created!
  text: { es: "Spanish text" } // 🐛 'en' locale is missing
}]
```
### After fix:
```js
// After fix
array: [{
  id: "123",  //  Same ID maintained
  text: {
    en: "English text",
    es: "Spanish text"  //  Properly merged with existing item
  }
}]
```


## Additional fixes:

### TraverseFields

In the process of designing an appropriate solution, I detected a couple
of bugs in traverseFields that are also addressed in this PR.

### Fixed MongoDB Empty Array Handling

During testing, I discovered that MongoDB and PostgreSQL behave
differently when querying documents that don't exist in a specific
locale:
- PostgreSQL: Returns the document with data from the fallback locale
- MongoDB: Returns the document with empty arrays for localized fields

This difference caused `copyToLocale` to fail in MongoDB because the
merge algorithm only checked for `null` or `undefined` values, but not
empty arrays. When MongoDB returned `content: []` for a non-existent
locale, the algorithm would attempt to iterate over the empty array
instead of using the source locale's data.

### Move test e2e to int

The test introduced in #11887 didn't catch the bug because our e2e suite
doesn't run on Postgres. I migrated the test to an integration test that
does run on Postgres and MongoDB.
2025-07-24 20:37:13 +01:00
Jarrod Flesch
fa7d209cc9 fix(ui): incorrect blocks label sizing (#13264)
Blocks container labels should match the Array and Tab labels. Uses same
styling approach as Array labels.

### Before
<img width="229" height="260" alt="CleanShot 2025-07-24 at 12 26 38"
src="https://github.com/user-attachments/assets/9c4eb7c5-3638-4b47-805b-1206f195f5eb"
/>

### After
<img width="245" height="259" alt="CleanShot 2025-07-24 at 12 27 00"
src="https://github.com/user-attachments/assets/c04933b4-226f-403b-9913-24ba00857aab"
/>
2025-07-24 19:34:29 +00:00
Jacob Fletcher
bccf6ab16f feat: group by (#13138)
Supports grouping documents by specific fields within the list view.

For example, imagine having a "posts" collection with a "categories"
field. To report on each specific category, you'd traditionally filter
for each category, one at a time. This can be quite inefficient,
especially with large datasets.

Now, you can interact with all categories simultaneously, grouped by
distinct values.

Here is a simple demonstration:


https://github.com/user-attachments/assets/0dcd19d2-e983-47e6-9ea2-cfdd2424d8b5

Enable on any collection by setting the `admin.groupBy` property:

```ts
import type { CollectionConfig } from 'payload'

const MyCollection: CollectionConfig = {
  // ...
  admin: {
    groupBy: true
  }
}
```

This is currently marked as beta to gather feedback while we reach full
stability, and to leave room for API changes and other modifications.
Use at your own risk.

Note: when using `groupBy`, bulk editing is done group-by-group. In the
future we may support cross-group bulk editing.

Dependent on #13102 (merged).

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210774523852467

---------

Co-authored-by: Paul Popus <paul@payloadcms.com>
2025-07-24 14:00:52 -04:00
Dan Ribbens
14322a71bb docs(plugin-import-export): document plugin-import-export (#13243)
Add documentation for @payloadcms/plugin-import-export.
2025-07-24 17:03:21 +00:00
Patrik
7e81d30808 fix(ui): ensure document unlocks when logging out from edit view of a locked document (#13142)
### What?

Refactors the `LeaveWithoutSaving` modal to be generic and delegates
document unlock logic back to the `DefaultEditView` component via a
callback.

### Why?

Previously, `unlockDocument` was triggered in a cleanup `useEffect` in
the edit view. When logging out from the edit view, the unlock request
would often fail due to the session ending — leaving the document in a
locked state.

### How?

- Introduced `onConfirm` and `onPrevent` props for `LeaveWithoutSaving`.
- Moved all document lock/unlock logic into `DefaultEditView`’s
`handleLeaveConfirm`.
- Captures the next navigation target via `onPrevent` and evaluates
whether to unlock based on:
  - Locking being enabled.
  - Current user owning the lock.
- Navigation not targeting internal admin views (`/preview`, `/api`,
`/versions`).

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-07-24 09:18:49 -07:00
Sasha
a83ed5ebb5 fix(db-postgres): search is broken when useAsTitle is not specified (#13232)
Fixes https://github.com/payloadcms/payload/issues/13171
2025-07-24 18:42:17 +03:00
Patrik
8f85da8931 fix(plugin-import-export): json preview and downloads preserve nesting and exclude disabled fields (#13210)
### What?

Improves both the JSON preview and export functionality in the
import-export plugin:
- Preserves proper nesting of object and array fields (e.g., groups,
tabs, arrays)
- Excludes any fields explicitly marked as `disabled` via
`custom.plugin-import-export`
- Ensures downloaded files use proper JSON formatting when `format` is
`json` (no CSV-style flattening)

### Why?

Previously:
- The JSON preview flattened all fields to a single level and included
disabled fields.
- Exported files with `format: json` were still CSV-style data encoded
as `.json`, rather than real JSON.

### How?

- Refactored `/preview-data` JSON handling to preserve original document
shape.
- Applied `removeDisabledFields` to clean nested fields using
dot-notation paths.
- Updated `createExport` to skip `flattenObject` for JSON formats, using
a nested JSON filter instead.
- Fixed streaming and buffered export paths to output valid JSON arrays
when `format` is `json`.
2025-07-24 11:36:46 -04:00
485 changed files with 20324 additions and 2907 deletions

View File

@@ -13,7 +13,8 @@ echo "${audit_json}" | jq --arg severity "${severity}" '
{
package: .value.module_name,
vulnerable: .value.vulnerable_versions,
fixed_in: .value.patched_versions
fixed_in: .value.patched_versions,
findings: .value.findings
}
)
' >$output_file
@@ -23,7 +24,11 @@ audit_length=$(jq 'length' $output_file)
if [[ "${audit_length}" -gt "0" ]]; then
echo "Actionable vulnerabilities found in the following packages:"
jq -r '.[] | "\u001b[1m\(.package)\u001b[0m vulnerable in \u001b[31m\(.vulnerable)\u001b[0m fixed in \u001b[32m\(.fixed_in)\u001b[0m"' $output_file | while read -r line; do echo -e "$line"; done
echo ""
echo "Output written to ${output_file}"
cat $output_file
echo ""
echo "This script can be rerun with: './.github/workflows/audit-dependencies.sh $severity'"
exit 1
else
echo "No actionable vulnerabilities"

View File

@@ -46,7 +46,7 @@ jobs:
"type": "section",
"text": {
"type": "mrkdwn",
"text": "🚨 Actionable vulnerabilities found: <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>"
"text": "🚨 Actionable vulnerabilities found: <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Script Run Details>"
}
},
]

View File

@@ -284,6 +284,7 @@ jobs:
- fields__collections__Text
- fields__collections__UI
- fields__collections__Upload
- group-by
- folders
- hooks
- lexical__collections__Lexical__e2e__main
@@ -303,6 +304,7 @@ jobs:
- plugin-nested-docs
- plugin-seo
- sort
- trash
- versions
- uploads
env:
@@ -419,6 +421,7 @@ jobs:
- fields__collections__Text
- fields__collections__UI
- fields__collections__Upload
- group-by
- folders
- hooks
- lexical__collections__Lexical__e2e__main
@@ -438,6 +441,7 @@ jobs:
- plugin-nested-docs
- plugin-seo
- sort
- trash
- versions
- uploads
env:

7
.vscode/launch.json vendored
View File

@@ -139,6 +139,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts trash",
"cwd": "${workspaceFolder}",
"name": "Run Dev Trash",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts uploads",
"cwd": "${workspaceFolder}",

View File

@@ -77,7 +77,7 @@ All auto-generated files will contain the following comments at the top of each
## Admin Options
All options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
All root-level options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
```ts
import { buildConfig } from 'payload'

View File

@@ -60,32 +60,33 @@ export const Posts: CollectionConfig = {
The following options are available:
| Option | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
| `custom` | Extension point for adding custom data (e.g. for plugins) |
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. [More details](../database/indexes#compound-indexes). |
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API |
| Option | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
| `custom` | Extension point for adding custom data (e.g. for plugins) |
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
| `trash` | A boolean to enable soft deletes for this collection. Defaults to `false`. [More details](../trash/overview). |
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API |
_\* An asterisk denotes that a property is required._
@@ -130,6 +131,7 @@ The following options are available:
| `description` | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#custom-components). |
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this Collection's List View. |
| `disableCopyToLocale` | Disables the "Copy to Locale" button while editing documents within this Collection. Only applicable when localization is enabled. |
| `groupBy` | Beta. Enable grouping by a field in the list view. |
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this Collection. |
| `enableRichTextLink` | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `enableRichTextRelationship` | The [Rich Text](../fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |

View File

@@ -34,20 +34,20 @@ npm i @payloadcms/plugin-csm
Then in the `plugins` array of your Payload Config, call the plugin and enable any collections that require Content Source Maps.
```ts
import { buildConfig } from "payload/config"
import contentSourceMaps from "@payloadcms/plugin-csm"
import { buildConfig } from 'payload/config'
import contentSourceMaps from '@payloadcms/plugin-csm'
const config = buildConfig({
collections: [
{
slug: "pages",
slug: 'pages',
fields: [
{
name: 'slug',
type: 'text',
},
{
name: 'title,'
name: 'title',
type: 'text',
},
],
@@ -55,7 +55,7 @@ const config = buildConfig({
],
plugins: [
contentSourceMaps({
collections: ["pages"],
collections: ['pages'],
}),
],
})

View File

@@ -77,7 +77,6 @@ This configuration only queues the Job - it does not execute it immediately. To
```ts
export default buildConfig({
jobs: {
scheduler: 'cron',
autoRun: [
{
cron: '* * * * *', // Runs every minute

View File

@@ -45,13 +45,11 @@ The following options are available:
| Path | Description |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`url`** \* | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). |
| **`url`** | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). |
| **`breakpoints`** | Array of breakpoints to be used as “device sizes” in the preview window. Each item appears as an option in the toolbar. [More details](#breakpoints). |
| **`collections`** | Array of collection slugs to enable Live Preview on. |
| **`globals`** | Array of global slugs to enable Live Preview on. |
_\* An asterisk denotes that a property is required._
### URL
The `url` property resolves to a string that points to your front-end application. This value is used as the `src` attribute of the iframe rendering your front-end. Once loaded, the Admin Panel will communicate directly with your app through `window.postMessage` events.
@@ -88,17 +86,16 @@ const config = buildConfig({
// ...
livePreview: {
// highlight-start
url: ({
data,
collectionConfig,
locale
}) => `${data.tenant.url}${ // Multi-tenant top-level domain
collectionConfig.slug === 'posts' ? `/posts/${data.slug}` : `${data.slug !== 'home' : `/${data.slug}` : ''}`
}${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param
url: ({ data, collectionConfig, locale }) =>
`${data.tenant.url}${
collectionConfig.slug === 'posts'
? `/posts/${data.slug}`
: `${data.slug !== 'home' ? `/${data.slug}` : ''}`
}${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param
collections: ['pages'],
},
// highlight-end
}
},
})
```

View File

@@ -51,6 +51,7 @@ export default async function Page() {
collection: 'pages',
id: '123',
draft: true,
trash: true, // add this if trash is enabled in your collection and want to preview trashed documents
})
return (

View File

@@ -1,7 +1,7 @@
---
title: Form Builder Plugin
label: Form Builder
order: 40
order: 30
desc: Easily build and manage forms from the Admin Panel. Send dynamic, personalized emails and even accept and process payments.
keywords: plugins, plugin, form, forms, form builder
---

View File

@@ -0,0 +1,155 @@
---
title: Import Export Plugin
label: Import Export
order: 40
desc: Add Import and export functionality to create CSV and JSON data exports
keywords: plugins, plugin, import, export, csv, JSON, data, ETL, download
---
![https://www.npmjs.com/package/@payloadcms/plugin-import-export](https://img.shields.io/npm/v/@payloadcms/plugin-import-export)
<Banner type="warning">
**Note**: This plugin is in **beta** as some aspects of it may change on any
minor releases. It is under development and currently only supports exporting
of collection data.
</Banner>
This plugin adds features that give admin users the ability to download or create export data as an upload collection and import it back into a project.
## Core Features
- Export data as CSV or JSON format via the admin UI
- Download the export directly through the browser
- Create a file upload of the export data
- Use the jobs queue for large exports
- (Coming soon) Import collection data
## Installation
Install the plugin using any JavaScript package manager like [pnpm](https://pnpm.io), [npm](https://npmjs.com), or [Yarn](https://yarnpkg.com):
```bash
pnpm add @payloadcms/plugin-import-export
```
## Basic Usage
In the `plugins` array of your [Payload Config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options):
```ts
import { buildConfig } from 'payload'
import { importExportPlugin } from '@payloadcms/plugin-import-export'
const config = buildConfig({
collections: [Pages, Media],
plugins: [
importExportPlugin({
collections: ['users', 'pages'],
// see below for a list of available options
}),
],
})
export default config
```
## Options
| Property | Type | Description |
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `collections` | string[] | Collections to include Import/Export controls in. Defaults to all collections. |
| `debug` | boolean | If true, enables debug logging. |
| `disableDownload` | boolean | If true, disables the download button in the export preview UI. |
| `disableJobsQueue` | boolean | If true, forces the export to run synchronously. |
| `disableSave` | boolean | If true, disables the save button in the export preview UI. |
| `format` | string | Forces a specific export format (`csv` or `json`), hides the format dropdown, and prevents the user from choosing the export format. |
| `overrideExportCollection` | function | Function to override the default export collection; takes the default export collection and allows you to modify and return it. |
## Field Options
In addition to the above plugin configuration options, you can granularly set the following field level options using the `custom['plugin-import-export']` properties in any of your collections.
| Property | Type | Description |
| ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | boolean | When `true` the field is completely excluded from the import-export plugin. |
| `toCSV` | function | Custom function used to modify the outgoing csv data by manipulating the data, siblingData or by returning the desired value. |
### Customizing the output of CSV data
To manipulate the data that a field exports you can add `toCSV` custom functions. This allows you to modify the outgoing csv data by manipulating the data, siblingData or by returning the desired value.
The toCSV function argument is an object with the following properties:
| Property | Type | Description |
| ------------ | ------- | ----------------------------------------------------------------- |
| `columnName` | string | The CSV column name given to the field. |
| `doc` | object | The top level document |
| `row` | object | The object data that can be manipulated to assign data to the CSV |
| `siblingDoc` | object | The document data at the level where it belongs |
| `value` | unknown | The data for the field. |
Example function:
```ts
const pages: CollectionConfig = {
slug: 'pages',
fields: [
{
name: 'author',
type: 'relationship',
relationTo: 'users',
custom: {
'plugin-import-export': {
toCSV: ({ value, columnName, row }) => {
// add both `author_id` and the `author_email` to the csv export
if (
value &&
typeof value === 'object' &&
'id' in value &&
'email' in value
) {
row[`${columnName}_id`] = (value as { id: number | string }).id
row[`${columnName}_email`] = (value as { email: string }).email
}
},
},
},
},
],
}
```
## Exporting Data
There are four possible ways that the plugin allows for exporting documents, the first two are available in the admin UI from the list view of a collection:
1. Direct download - Using a `POST` to `/api/exports/download` and streams the response as a file download
2. File storage - Goes to the `exports` collection as an uploads enabled collection
3. Local API - A create call to the uploads collection: `payload.create({ slug: 'uploads', ...parameters })`
4. Jobs Queue - `payload.jobs.queue({ task: 'createCollectionExport', input: parameters })`
By default, a user can use the Export drawer to create a file download by choosing `Save` or stream a downloadable file directly without persisting it by using the `Download` button. Either option can be disabled to provide the export experience you desire for your use-case.
The UI for creating exports provides options so that users can be selective about which documents to include and also which columns or fields to include.
It is necessary to add access control to the uploads collection configuration using the `overrideExportCollection` function if you have enabled this plugin on collections with data that some authenticated users should not have access to.
<Banner type="warning">
**Note**: Users who have read access to the upload collection may be able to
download data that is normally not readable due to [access
control](../access-control/overview).
</Banner>
The following parameters are used by the export function to handle requests:
| Property | Type | Description |
| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `format` | text | Either `csv` or `json` to determine the shape of data exported |
| `limit` | number | The max number of documents to return |
| `sort` | select | The field to use for ordering documents |
| `locale` | string | The locale code to query documents or `all` |
| `draft` | string | Either `yes` or `no` to return documents with their newest drafts for drafts enabled collections |
| `fields` | string[] | Which collection fields are used to create the export, defaults to all |
| `collectionSlug` | string | The slug to query against |
| `where` | object | The WhereObject used to query documents to export. This is set by making selections or filters from the list view |
| `filename` | text | What to call the export being created |

View File

@@ -1,7 +1,7 @@
---
title: Multi-Tenant Plugin
label: Multi-Tenant
order: 40
order: 50
desc: Scaffolds multi-tenancy for your Payload application
keywords: plugins, multi-tenant, multi-tenancy, plugin, payload, cms, seo, indexing, search, search engine
---
@@ -229,15 +229,15 @@ const config = buildConfig({
{
slug: 'tenants',
admin: {
useAsTitle: 'name'
useAsTitle: 'name',
},
fields: [
// remember, you own these fields
// these are merely suggestions/examples
{
name: 'name',
type: 'text',
required: true,
name: 'name',
type: 'text',
required: true,
},
{
name: 'slug',
@@ -248,7 +248,7 @@ const config = buildConfig({
name: 'domain',
type: 'text',
required: true,
}
},
],
},
],
@@ -258,7 +258,7 @@ const config = buildConfig({
pages: {},
navigation: {
isGlobal: true,
}
},
},
}),
],

View File

@@ -1,7 +1,7 @@
---
title: Nested Docs Plugin
label: Nested Docs
order: 40
order: 60
desc: Nested documents in a parent, child, and sibling relationship.
keywords: plugins, nested, documents, parent, child, sibling, relationship
---

View File

@@ -55,6 +55,7 @@ Payload maintains a set of Official Plugins that solve for some of the common us
- [Sentry](./sentry)
- [SEO](./seo)
- [Stripe](./stripe)
- [Import/Export](./import-export)
You can also [build your own plugin](./build-your-own) to easily extend Payload's functionality in some other way. Once your plugin is ready, consider [sharing it with the community](#community-plugins).

View File

@@ -1,7 +1,7 @@
---
title: Redirects Plugin
label: Redirects
order: 40
order: 70
desc: Automatically create redirects for your Payload application
keywords: plugins, redirects, redirect, plugin, payload, cms, seo, indexing, search, search engine
---

View File

@@ -1,7 +1,7 @@
---
title: Search Plugin
label: Search
order: 40
order: 80
desc: Generates records of your documents that are extremely fast to search on.
keywords: plugins, search, search plugin, search engine, search index, search results, search bar, search box, search field, search form, search input
---

View File

@@ -1,7 +1,7 @@
---
title: Sentry Plugin
label: Sentry
order: 40
order: 90
desc: Integrate Sentry error tracking into your Payload application
keywords: plugins, sentry, error, tracking, monitoring, logging, bug, reporting, performance
---

View File

@@ -2,7 +2,7 @@
description: Manage SEO metadata from your Payload admin
keywords: plugins, seo, meta, search, engine, ranking, google
label: SEO
order: 30
order: 100
title: SEO Plugin
---

View File

@@ -1,7 +1,7 @@
---
title: Stripe Plugin
label: Stripe
order: 40
order: 110
desc: Easily accept payments with Stripe
keywords: plugins, stripe, payments, ecommerce
---

200
docs/trash/overview.mdx Normal file
View File

@@ -0,0 +1,200 @@
---
title: Trash
label: Overview
order: 10
desc: Enable soft deletes for your collections to mark documents as deleted without permanently removing them.
keywords: trash, soft delete, deletedAt, recovery, restore
---
Trash (also known as soft delete) allows documents to be marked as deleted without being permanently removed. When enabled on a collection, deleted documents will receive a `deletedAt` timestamp, making it possible to restore them later, view them in a dedicated Trash view, or permanently delete them.
Soft delete is a safer way to manage content lifecycle, giving editors a chance to review and recover documents that may have been deleted by mistake.
<Banner type="warning">
**Note:** The Trash feature is currently in beta and may be subject to change
in minor version updates.
</Banner>
## Collection Configuration
To enable soft deleting for a collection, set the `trash` property to `true`:
```ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
trash: true,
fields: [
{
name: 'title',
type: 'text',
},
// other fields...
],
}
```
When enabled, Payload automatically injects a deletedAt field into the collection's schema. This timestamp is set when a document is soft-deleted, and cleared when the document is restored.
## Admin Panel behavior
Once `trash` is enabled, the Admin Panel provides a dedicated Trash view for each collection:
- A new route is added at `/collections/:collectionSlug/trash`
- The `Trash` view shows all documents that have a `deletedAt` timestamp
From the Trash view, you can:
- Use bulk actions to manage trashed documents:
- **Restore** to clear the `deletedAt` timestamp and return documents to their original state
- **Delete** to permanently remove selected documents
- **Empty Trash** to select and permanently delete all trashed documents at once
- Enter each document's **edit view**, just like in the main list view. While in the edit view of a trashed document:
- All fields are in a **read-only** state
- Standard document actions (e.g., Save, Publish, Restore Version) are hidden and disabled.
- The available actions are **Restore** and **Permanently Delete**.
- Access to the **API**, **Versions**, and **Preview** views is preserved.
When deleting a document from the main collection List View, Payload will soft-delete the document by default. A checkbox in the delete confirmation modal allows users to skip the trash and permanently delete instead.
## API Support
Soft deletes are fully supported across all Payload APIs: **Local**, **REST**, and **GraphQL**.
The following operations respect and support the `trash` functionality:
- `find`
- `findByID`
- `update`
- `updateByID`
- `delete`
- `deleteByID`
- `findVersions`
- `findVersionByID`
### Understanding `trash` Behavior
Passing `trash: true` to these operations will **include soft-deleted documents** in the query results.
To return _only_ soft-deleted documents, you must combine `trash: true` with a `where` clause that checks if `deletedAt` exists.
### Examples
#### Local API
Return all documents including trashed:
```ts
const result = await payload.find({
collection: 'posts',
trash: true,
})
```
Return only trashed documents:
```ts
const result = await payload.find({
collection: 'posts',
trash: true,
where: {
deletedAt: {
exists: true,
},
},
})
```
Return only non-trashed documents:
```ts
const result = await payload.find({
collection: 'posts',
trash: false,
})
```
#### REST
Return **all** documents including trashed:
```http
GET /api/posts?trash=true
```
Return **only trashed** documents:
```http
GET /api/posts?trash=true&where[deletedAt][exists]=true
```
Return only non-trashed documents:
```http
GET /api/posts?trash=false
```
#### GraphQL
Return all documents including trashed:
```ts
query {
Posts(trash: true) {
docs {
id
deletedAt
}
}
}
```
Return only trashed documents:
```ts
query {
Posts(
trash: true
where: { deletedAt: { exists: true } }
) {
docs {
id
deletedAt
}
}
}
```
Return only non-trashed documents:
```ts
query {
Posts(trash: false) {
docs {
id
deletedAt
}
}
}
```
## Access Control
All trash-related actions (delete, permanent delete) respect the `delete` access control defined in your collection config.
This means:
- If a user is denied delete access, they cannot soft delete or permanently delete documents
## Versions and Trash
When a document is soft-deleted:
- It can no longer have a version **restored** until it is first restored from trash
- Attempting to restore a version while the document is in trash will result in an error
- This ensures consistency between the current document state and its version history
However, versions are still fully **visible and accessible** from the **edit view** of a trashed document. You can view the full version history, but must restore the document itself before restoring any individual version.

View File

@@ -90,33 +90,33 @@ export const Media: CollectionConfig = {
_An asterisk denotes that an option is required._
| Option | Description |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. |
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) |
| Option | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. If using this option, you should handle the removal of any sensitive cookies (like payload-prefixed cookies) to prevent leaking session information to external services. By default, Payload automatically filters out payload-prefixed cookies when this option is not defined. |
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) |
### Payload-wide Upload Options

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.48.0",
"version": "3.49.1",
"private": true,
"type": "module",
"workspaces": [
@@ -132,12 +132,12 @@
"devDependencies": {
"@jest/globals": "29.7.0",
"@libsql/client": "0.14.0",
"@next/bundle-analyzer": "15.3.2",
"@next/bundle-analyzer": "15.4.4",
"@payloadcms/db-postgres": "workspace:*",
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/eslint-plugin": "workspace:*",
"@payloadcms/live-preview-react": "workspace:*",
"@playwright/test": "1.50.0",
"@playwright/test": "1.54.1",
"@sentry/nextjs": "^8.33.1",
"@sentry/node": "^8.33.1",
"@swc-node/register": "1.10.10",
@@ -147,8 +147,8 @@
"@types/jest": "29.5.12",
"@types/minimist": "1.2.5",
"@types/node": "22.15.30",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/shelljs": "0.8.15",
"chalk": "^4.1.2",
"comment-json": "^4.2.3",
@@ -168,12 +168,12 @@
"lint-staged": "15.2.7",
"minimist": "1.2.8",
"mongodb-memory-server": "10.1.4",
"next": "15.3.2",
"next": "15.4.4",
"open": "^10.1.0",
"p-limit": "^5.0.0",
"pg": "8.16.3",
"playwright": "1.50.0",
"playwright-core": "1.50.0",
"playwright": "1.54.1",
"playwright-core": "1.54.1",
"prettier": "3.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
"version": "3.48.0",
"version": "3.49.1",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -42,8 +42,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.48.0",
"version": "3.49.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.48.0",
"version": "3.49.1",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.48.0",
"version": "3.49.1",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.48.0",
"version": "3.49.1",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -29,8 +29,8 @@ export const countDistinct: CountDistinct = async function countDistinct(
.limit(1)
.$dynamic()
joins.forEach(({ condition, table }) => {
query = query.leftJoin(table, condition)
joins.forEach(({ type, condition, table }) => {
query = query[type ?? 'leftJoin'](table, condition)
})
// When we have any joins, we need to count each individual ID only once.

View File

@@ -60,6 +60,10 @@ const createConstraint = ({
formattedOperator = '='
}
if (pathSegments.length === 1) {
return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')`
}
return `EXISTS (
SELECT 1
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
@@ -68,21 +72,38 @@ const createConstraint = ({
}
export const createJSONQuery = ({
column,
operator,
pathSegments,
rawColumn,
table,
treatAsArray,
treatRootAsArray,
value,
}: CreateJSONQueryArgs): string => {
if ((operator === 'in' || operator === 'not_in') && Array.isArray(value)) {
let sql = ''
for (const [i, v] of value.entries()) {
sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, rawColumn, table, treatAsArray, treatRootAsArray, value: v })} ${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}`
}
return sql
}
if (treatAsArray?.includes(pathSegments[1]!) && table) {
return fromArray({
operator,
pathSegments,
table,
treatAsArray,
value,
value: value as CreateConstraintArgs['value'],
})
}
return createConstraint({ alias: table, operator, pathSegments, treatAsArray, value })
return createConstraint({
alias: table,
operator,
pathSegments,
treatAsArray,
value: value as CreateConstraintArgs['value'],
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.48.0",
"version": "3.49.1",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.48.0",
"version": "3.49.1",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -6,41 +6,58 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import { findMany } from './find/findMany.js'
import { buildQuery } from './queries/buildQuery.js'
import { getTransaction } from './utilities/getTransaction.js'
export const deleteMany: DeleteMany = async function deleteMany(
this: DrizzleAdapter,
{ collection, req, where },
{ collection, req, where: whereArg },
) {
const db = await getTransaction(this, req)
const collectionConfig = this.payload.collections[collection].config
const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug))
const result = await findMany({
const table = this.tables[tableName]
const { joins, where } = buildQuery({
adapter: this,
fields: collectionConfig.flattenedFields,
joins: false,
limit: 0,
locale: req?.locale,
page: 1,
pagination: false,
req,
tableName,
where,
where: whereArg,
})
const ids = []
let whereToUse = where
result.docs.forEach((data) => {
ids.push(data.id)
})
if (ids.length > 0) {
await this.deleteWhere({
db,
if (joins?.length) {
// Difficult to support joins (through where referencing other tables) in deleteMany. => 2 separate queries.
// We can look into supporting this using one single query (through a subquery) in the future, though that's difficult to do in a generic way.
const result = await findMany({
adapter: this,
fields: collectionConfig.flattenedFields,
joins: false,
limit: 0,
locale: req?.locale,
page: 1,
pagination: false,
req,
select: {
id: true,
},
tableName,
where: inArray(this.tables[tableName].id, ids),
where: whereArg,
})
whereToUse = inArray(
table.id,
result.docs.map((doc) => doc.id),
)
}
await this.deleteWhere({
db,
tableName,
where: whereToUse,
})
}

View File

@@ -1,12 +1,14 @@
import type { SQL } from 'drizzle-orm'
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { SQLiteSelect, SQLiteSelectBase } from 'drizzle-orm/sqlite-core'
import { and, asc, count, desc, eq, or, sql } from 'drizzle-orm'
import { and, asc, count, desc, eq, getTableName, or, sql } from 'drizzle-orm'
import {
appendVersionToQueryKey,
buildVersionCollectionFields,
combineQueries,
type FlattenedField,
getFieldByPath,
getQueryDraftsSort,
type JoinQuery,
type SelectMode,
@@ -31,7 +33,7 @@ import {
resolveBlockTableName,
} from '../utilities/validateExistingBlockIsIdentical.js'
const flattenAllWherePaths = (where: Where, paths: string[]) => {
const flattenAllWherePaths = (where: Where, paths: { path: string; ref: any }[]) => {
for (const k in where) {
if (['AND', 'OR'].includes(k.toUpperCase())) {
if (Array.isArray(where[k])) {
@@ -41,7 +43,7 @@ const flattenAllWherePaths = (where: Where, paths: string[]) => {
}
} else {
// TODO: explore how to support arrays/relationship querying.
paths.push(k.split('.').join('_'))
paths.push({ path: k.split('.').join('_'), ref: where })
}
}
}
@@ -59,7 +61,11 @@ const buildSQLWhere = (where: Where, alias: string) => {
}
} else {
const payloadOperator = Object.keys(where[k])[0]
const value = where[k][payloadOperator]
if (payloadOperator === '$raw') {
return sql.raw(value)
}
return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value)
}
@@ -472,7 +478,7 @@ export const traverseFields = ({
const sortPath = sanitizedSort.split('.').join('_')
const wherePaths: string[] = []
const wherePaths: { path: string; ref: any }[] = []
if (where) {
flattenAllWherePaths(where, wherePaths)
@@ -492,9 +498,50 @@ export const traverseFields = ({
sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'),
}
const collectionQueryWhere: any[] = []
// Select for WHERE and Fallback NULL
for (const path of wherePaths) {
if (adapter.tables[joinCollectionTableName][path]) {
for (const { path, ref } of wherePaths) {
const collectioConfig = adapter.payload.collections[collection].config
const field = getFieldByPath({ fields: collectioConfig.flattenedFields, path })
if (field && field.field.type === 'select' && field.field.hasMany) {
let tableName = adapter.tableNameMap.get(
`${toSnakeCase(collection)}_${toSnakeCase(path)}`,
)
let parentTable = getTableName(table)
if (adapter.schemaName) {
tableName = `"${adapter.schemaName}"."${tableName}"`
parentTable = `"${adapter.schemaName}"."${parentTable}"`
}
if (adapter.name === 'postgres') {
selectFields[path] = sql
.raw(
`(select jsonb_agg(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`,
)
.as(path)
} else {
selectFields[path] = sql
.raw(
`(select json_group_array(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`,
)
.as(path)
}
const constraint = ref[path]
const operator = Object.keys(constraint)[0]
const value: any = Object.values(constraint)[0]
const query = adapter.createJSONQuery({
column: `"${path}"`,
operator,
pathSegments: [field.field.name],
table: parentTable,
value,
})
ref[path] = { $raw: query }
} else if (adapter.tables[joinCollectionTableName][path]) {
selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path)
// Allow to filter by collectionSlug
} else if (path !== 'relationTo') {
@@ -502,7 +549,10 @@ export const traverseFields = ({
}
}
const query = db.select(selectFields).from(adapter.tables[joinCollectionTableName])
let query: any = db.select(selectFields).from(adapter.tables[joinCollectionTableName])
if (collectionQueryWhere.length) {
query = query.where(and(...collectionQueryWhere))
}
if (currentQuery === null) {
currentQuery = query as unknown as SQLSelect
} else {

View File

@@ -30,8 +30,8 @@ export const countDistinct: CountDistinct = async function countDistinct(
.limit(1)
.$dynamic()
joins.forEach(({ condition, table }) => {
query = query.leftJoin(table as PgTableWithColumns<any>, condition)
joins.forEach(({ type, condition, table }) => {
query = query[type ?? 'leftJoin'](table as PgTableWithColumns<any>, condition)
})
// When we have any joins, we need to count each individual ID only once.

View File

@@ -28,6 +28,8 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
})
.join('.')
const fullPath = pathSegments.length === 1 ? '$[*]' : `$.${jsonPaths}`
let sql = ''
if (['in', 'not_in'].includes(operator) && Array.isArray(value)) {
@@ -35,13 +37,13 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, value: item })}${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}`
})
} else if (operator === 'exists') {
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')`
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '${fullPath}')`
} else if (['not_like'].includes(operator)) {
const mappedOperator = operatorMap[operator]
sql = `NOT jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')`
sql = `NOT jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')`
} else {
sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
sql = `jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
}
return sql

View File

@@ -219,7 +219,10 @@ export function parseParams({
if (
operator === 'like' &&
(field.type === 'number' || table[columnName].columnType === 'PgUUID')
(field.type === 'number' ||
field.type === 'relationship' ||
field.type === 'upload' ||
table[columnName].columnType === 'PgUUID')
) {
operator = 'equals'
}

View File

@@ -112,9 +112,14 @@ export const sanitizeQueryValue = ({
if (field.type === 'date' && operator !== 'exists') {
if (typeof val === 'string') {
formattedValue = new Date(val).toISOString()
if (Number.isNaN(Date.parse(formattedValue))) {
return { operator, value: undefined }
if (val === 'null' || val === '') {
formattedValue = null
} else {
const date = new Date(val)
if (Number.isNaN(date.getTime())) {
return { operator, value: undefined }
}
formattedValue = date.toISOString()
}
} else if (typeof val === 'number') {
formattedValue = new Date(val).toISOString()

View File

@@ -56,8 +56,8 @@ export const selectDistinct = ({
query = query.where(where)
}
joins.forEach(({ condition, table }) => {
query = query.leftJoin(table, condition)
joins.forEach(({ type, condition, table }) => {
query = query[type ?? 'leftJoin'](table, condition)
})
return queryModifier({

View File

@@ -161,10 +161,11 @@ export type CreateJSONQueryArgs = {
column?: Column | string
operator: string
pathSegments: string[]
rawColumn?: SQL<unknown>
table?: string
treatAsArray?: string[]
treatRootAsArray?: boolean
value: boolean | number | string
value: boolean | number | number[] | string | string[]
}
/**

View File

@@ -6,6 +6,7 @@ import type { DrizzleAdapter } from './types.js'
import { findMany } from './find/findMany.js'
import { upsertRow } from './upsertRow/index.js'
import { shouldUseOptimizedUpsertRow } from './upsertRow/shouldUseOptimizedUpsertRow.js'
import { getTransaction } from './utilities/getTransaction.js'
export const updateJobs: UpdateJobs = async function updateMany(
@@ -23,6 +24,27 @@ export const updateJobs: UpdateJobs = async function updateMany(
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))
const sort = sortArg !== undefined && sortArg !== null ? sortArg : collection.defaultSort
const useOptimizedUpsertRow = shouldUseOptimizedUpsertRow({
data,
fields: collection.flattenedFields,
})
if (useOptimizedUpsertRow && id) {
const result = await upsertRow({
id,
adapter: this,
data,
db,
fields: collection.flattenedFields,
ignoreResult: returning === false,
operation: 'update',
req,
tableName,
})
return returning === false ? null : [result]
}
const jobs = await findMany({
adapter: this,
collectionSlug: 'payload-jobs',
@@ -42,10 +64,12 @@ export const updateJobs: UpdateJobs = async function updateMany(
// TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows.
for (const job of jobs.docs) {
const updateData = {
...job,
...data,
}
const updateData = useOptimizedUpsertRow
? data
: {
...job,
...data,
}
const result = await upsertRow({
id: job.id,

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.48.0",
"version": "3.49.1",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.48.0",
"version": "3.49.1",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.48.0",
"version": "3.49.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -9,6 +9,7 @@ export type Resolver = (
args: {
data: Record<string, unknown>
locale?: string
trash?: boolean
where?: Where
},
context: {
@@ -30,6 +31,7 @@ export function countResolver(collection: Collection): Resolver {
const options = {
collection,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
where: args.where,
}

View File

@@ -11,6 +11,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
@@ -49,6 +50,7 @@ export function getDeleteResolver<TSlug extends CollectionSlug>(
collection,
depth: 0,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}
const result = await deleteByIDOperation(options)

View File

@@ -15,6 +15,7 @@ export type Resolver = (
page?: number
pagination?: boolean
sort?: string
trash?: boolean
where?: Where
},
context: {
@@ -57,6 +58,7 @@ export function findResolver(collection: Collection): Resolver {
pagination: args.pagination,
req,
sort: args.sort,
trash: args.trash,
where: args.where,
}

View File

@@ -11,6 +11,7 @@ export type Resolver<TData> = (
fallbackLocale?: string
id: string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
@@ -50,6 +51,7 @@ export function findByIDResolver<TSlug extends CollectionSlug>(
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}
const result = await findByIDOperation(options)

View File

@@ -10,6 +10,7 @@ export type Resolver<T extends TypeWithID = any> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
@@ -33,6 +34,7 @@ export function findVersionByIDResolver(collection: Collection): Resolver {
collection,
depth: 0,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}
const result = await findVersionByIDOperation(options)

View File

@@ -14,6 +14,7 @@ export type Resolver = (
page?: number
pagination?: boolean
sort?: string
trash?: boolean
where: Where
},
context: {
@@ -54,6 +55,7 @@ export function findVersionsResolver(collection: Collection): Resolver {
pagination: args.pagination,
req: isolateObjectProperty(req, 'transactionID'),
sort: args.sort,
trash: args.trash,
where: args.where,
}

View File

@@ -13,6 +13,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
@@ -54,6 +55,7 @@ export function updateResolver<TSlug extends CollectionSlug>(
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}
const result = await updateByIDOperation<TSlug>(options)

View File

@@ -205,6 +205,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
locale: { type: graphqlResult.types.localeInputType },
}
: {}),
trash: { type: GraphQLBoolean },
},
resolve: findByIDResolver(collection),
}
@@ -224,6 +225,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
page: { type: GraphQLInt },
pagination: { type: GraphQLBoolean },
sort: { type: GraphQLString },
trash: { type: GraphQLBoolean },
},
resolve: findResolver(collection),
}
@@ -237,6 +239,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
}),
args: {
draft: { type: GraphQLBoolean },
trash: { type: GraphQLBoolean },
where: { type: collection.graphQL.whereInputType },
...(config.localization
? {
@@ -292,6 +295,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
locale: { type: graphqlResult.types.localeInputType },
}
: {}),
trash: { type: GraphQLBoolean },
},
resolve: updateResolver(collection),
}
@@ -300,6 +304,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
type: collection.graphQL.type,
args: {
id: { type: new GraphQLNonNull(idType) },
trash: { type: GraphQLBoolean },
},
resolve: getDeleteResolver(collection),
}
@@ -329,12 +334,12 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
{
name: 'createdAt',
type: 'date',
label: 'Created At',
label: ({ t }) => t('general:createdAt'),
},
{
name: 'updatedAt',
type: 'date',
label: 'Updated At',
label: ({ t }) => t('general:updatedAt'),
},
]
@@ -359,6 +364,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
locale: { type: graphqlResult.types.localeInputType },
}
: {}),
trash: { type: GraphQLBoolean },
},
resolve: findVersionByIDResolver(collection),
}
@@ -385,6 +391,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
page: { type: GraphQLInt },
pagination: { type: GraphQLBoolean },
sort: { type: GraphQLString },
trash: { type: GraphQLBoolean },
},
resolve: findVersionsResolver(collection),
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.48.0",
"version": "3.49.1",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {
@@ -46,8 +46,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.48.0",
"version": "3.49.1",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.48.0",
"version": "3.49.1",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.48.0",
"version": "3.49.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -117,11 +117,11 @@
"@babel/preset-env": "7.27.2",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
"@next/eslint-plugin-next": "15.3.2",
"@next/eslint-plugin-next": "15.4.4",
"@payloadcms/eslint-config": "workspace:*",
"@types/busboy": "1.5.4",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/uuid": "10.0.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"esbuild": "0.25.5",

View File

@@ -38,9 +38,12 @@ export const DocumentTabLink: React.FC<{
path: `/${isCollection ? 'collections' : 'globals'}/${entitySlug}`,
})
if (isCollection && segmentThree) {
// doc ID
docPath += `/${segmentThree}`
if (isCollection) {
if (segmentThree === 'trash' && segmentFour) {
docPath += `/trash/${segmentFour}`
} else if (segmentThree) {
docPath += `/${segmentThree}`
}
}
const href = `${docPath}${hrefFromProps}`

View File

@@ -1,4 +1,11 @@
import type { DocumentTabConfig, DocumentTabServerProps, ServerProps } from 'payload'
import type {
DocumentTabConfig,
DocumentTabServerPropsOnly,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
} from 'payload'
import type React from 'react'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
@@ -9,27 +16,24 @@ import './index.scss'
export const baseClass = 'doc-tab'
export const DocumentTab: React.FC<
{ readonly Pill_Component?: React.FC } & DocumentTabConfig & DocumentTabServerProps
> = (props) => {
export const DefaultDocumentTab: React.FC<{
apiURL?: string
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
path?: string
permissions?: SanitizedPermissions
req: PayloadRequest
tabConfig: { readonly Pill_Component?: React.FC } & DocumentTabConfig
}> = (props) => {
const {
apiURL,
collectionConfig,
globalConfig,
href: tabHref,
i18n,
isActive: tabIsActive,
label,
newTab,
payload,
permissions,
Pill,
Pill_Component,
req,
tabConfig: { href: tabHref, isActive: tabIsActive, label, newTab, Pill, Pill_Component },
} = props
const { config } = payload
const { routes } = config
let href = typeof tabHref === 'string' ? tabHref : ''
let isActive = typeof tabIsActive === 'boolean' ? tabIsActive : false
@@ -38,7 +42,7 @@ export const DocumentTab: React.FC<
apiURL,
collection: collectionConfig,
global: globalConfig,
routes,
routes: req.payload.config.routes,
})
}
@@ -51,13 +55,13 @@ export const DocumentTab: React.FC<
const labelToRender =
typeof label === 'function'
? label({
t: i18n.t,
t: req.i18n.t,
})
: label
return (
<DocumentTabLink
adminRoute={routes.admin}
adminRoute={req.payload.config.routes.admin}
ariaLabel={labelToRender}
baseClass={baseClass}
href={href}
@@ -72,12 +76,14 @@ export const DocumentTab: React.FC<
{RenderServerComponent({
Component: Pill,
Fallback: Pill_Component,
importMap: payload.importMap,
importMap: req.payload.importMap,
serverProps: {
i18n,
payload,
i18n: req.i18n,
payload: req.payload,
permissions,
} satisfies ServerProps,
req,
user: req.user,
} satisfies DocumentTabServerPropsOnly,
})}
</Fragment>
) : null}

View File

@@ -1,8 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import type {
DocumentTabClientProps,
DocumentTabServerPropsOnly,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
@@ -12,7 +11,7 @@ import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerCompo
import React from 'react'
import { ShouldRenderTabs } from './ShouldRenderTabs.js'
import { DocumentTab } from './Tab/index.js'
import { DefaultDocumentTab } from './Tab/index.js'
import { getTabs } from './tabs/index.js'
import './index.scss'
@@ -21,12 +20,10 @@ const baseClass = 'doc-tabs'
export const DocumentTabs: React.FC<{
collectionConfig: SanitizedCollectionConfig
globalConfig: SanitizedGlobalConfig
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
}> = (props) => {
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload
req: PayloadRequest
}> = ({ collectionConfig, globalConfig, permissions, req }) => {
const { config } = req.payload
const tabs = getTabs({
collectionConfig,
@@ -38,42 +35,46 @@ export const DocumentTabs: React.FC<{
<div className={baseClass}>
<div className={`${baseClass}__tabs-container`}>
<ul className={`${baseClass}__tabs`}>
{tabs?.map(({ tab, viewPath }, index) => {
const { condition } = tab || {}
{tabs?.map(({ tab: tabConfig, viewPath }, index) => {
const { condition } = tabConfig || {}
const meetsCondition =
!condition || condition({ collectionConfig, config, globalConfig, permissions })
!condition ||
condition({ collectionConfig, config, globalConfig, permissions, req })
if (!meetsCondition) {
return null
}
if (tab?.Component) {
if (tabConfig?.Component) {
return RenderServerComponent({
clientProps: {
path: viewPath,
} satisfies DocumentTabClientProps,
Component: tab.Component,
importMap: payload.importMap,
Component: tabConfig.Component,
importMap: req.payload.importMap,
key: `tab-${index}`,
serverProps: {
collectionConfig,
globalConfig,
i18n,
payload,
i18n: req.i18n,
payload: req.payload,
permissions,
req,
user: req.user,
} satisfies DocumentTabServerPropsOnly,
})
}
return (
<DocumentTab
<DefaultDocumentTab
collectionConfig={collectionConfig}
globalConfig={globalConfig}
key={`tab-${index}`}
path={viewPath}
{...{
...props,
...tab,
}}
permissions={permissions}
req={req}
tabConfig={tabConfig}
/>
)
})}

View File

@@ -1,6 +1,6 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
@@ -18,11 +18,10 @@ export const DocumentHeader: React.FC<{
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
req: PayloadRequest
}> = (props) => {
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props
const { collectionConfig, globalConfig, hideTabs, permissions, req } = props
return (
<Gutter className={baseClass}>
@@ -31,9 +30,8 @@ export const DocumentHeader: React.FC<{
<DocumentTabs
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
)}
</Gutter>

View File

@@ -5,8 +5,6 @@ import type {
SanitizedGlobalConfig,
} from 'payload'
import { fieldAffectsData } from 'payload/shared'
import { getRouteWithoutAdmin, isAdminRoute } from './shared.js'
type Args = {
@@ -35,7 +33,7 @@ export function getRouteInfo({
if (isAdminRoute({ adminRoute, config, route })) {
const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route })
const routeSegments = routeWithoutAdmin.split('/').filter(Boolean)
const [entityType, entitySlug, createOrID] = routeSegments
const [entityType, entitySlug, segment3, segment4] = routeSegments
const collectionSlug = entityType === 'collections' ? entitySlug : undefined
const globalSlug = entityType === 'globals' ? entitySlug : undefined
@@ -58,12 +56,17 @@ export function getRouteInfo({
}
}
const docID =
collectionSlug && createOrID !== 'create'
? idType === 'number'
? Number(createOrID)
: createOrID
: undefined
let docID: number | string | undefined
if (collectionSlug) {
if (segment3 === 'trash' && segment4) {
// /collections/:slug/trash/:id
docID = idType === 'number' ? Number(segment4) : segment4
} else if (segment3 && segment3 !== 'create') {
// /collections/:slug/:id
docID = idType === 'number' ? Number(segment3) : segment3
}
}
return {
collectionConfig,

View File

@@ -15,16 +15,18 @@ import {
useTranslation,
} from '@payloadcms/ui'
import { useSearchParams } from 'next/navigation.js'
import * as React from 'react'
import './index.scss'
import * as React from 'react'
import { LocaleSelector } from './LocaleSelector/index.js'
import { RenderJSON } from './RenderJSON/index.js'
const baseClass = 'query-inspector'
export const APIViewClient: React.FC = () => {
const { id, collectionSlug, globalSlug, initialData } = useDocumentInfo()
const { id, collectionSlug, globalSlug, initialData, isTrashed } = useDocumentInfo()
const searchParams = useSearchParams()
const { i18n, t } = useTranslation()
@@ -69,10 +71,13 @@ export const APIViewClient: React.FC = () => {
const [authenticated, setAuthenticated] = React.useState<boolean>(true)
const [fullscreen, setFullscreen] = React.useState<boolean>(false)
const trashParam = typeof initialData?.deletedAt === 'string'
const params = new URLSearchParams({
depth,
draft: String(draft),
locale,
trash: trashParam ? 'true' : 'false',
}).toString()
const fetchURL = `${serverURL}${apiRoute}${docEndpoint}?${params}`
@@ -114,6 +119,7 @@ export const APIViewClient: React.FC = () => {
globalLabel={globalConfig?.label}
globalSlug={globalSlug}
id={id}
isTrashed={isTrashed}
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
view="API"

View File

@@ -137,9 +137,8 @@ export async function Account({ initPageResult, params, searchParams }: AdminVie
<DocumentHeader
collectionConfig={collectionConfig}
hideTabs
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
<HydrateAuthProvider permissions={permissions} />
{RenderServerComponent({

View File

@@ -0,0 +1,40 @@
import type { AdminViewServerProps, ListQuery } from 'payload'
import type React from 'react'
import { notFound } from 'next/navigation.js'
import { renderListView } from '../List/index.js'
type RenderTrashViewArgs = {
customCellProps?: Record<string, any>
disableBulkDelete?: boolean
disableBulkEdit?: boolean
disableQueryPresets?: boolean
drawerSlug?: string
enableRowSelections: boolean
overrideEntityVisibility?: boolean
query: ListQuery
redirectAfterDelete?: boolean
redirectAfterDuplicate?: boolean
redirectAfterRestore?: boolean
} & AdminViewServerProps
export const TrashView: React.FC<Omit<RenderTrashViewArgs, 'enableRowSelections'>> = async (
args,
) => {
try {
const { List: TrashList } = await renderListView({
...args,
enableRowSelections: true,
trash: true,
viewType: 'trash',
})
return TrashList
} catch (error) {
if (error.message === 'not-found') {
notFound()
}
console.error(error) // eslint-disable-line no-console
}
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from 'next'
import type { SanitizedCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import type { GenerateViewMetadata } from '../Root/index.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateCollectionTrashMetadata = async (
args: {
collectionConfig: SanitizedCollectionConfig
} & Parameters<GenerateViewMetadata>[0],
): Promise<Metadata> => {
const { collectionConfig, config, i18n } = args
let title: string = ''
const description: string = ''
const keywords: string = ''
if (collectionConfig) {
title = getTranslation(collectionConfig.labels.plural, i18n)
}
title = `${title ? `${title} ` : title}${i18n.t('general:trash')}`
return generateMetadata({
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
...(collectionConfig?.admin?.meta || {}),
})
}

View File

@@ -85,7 +85,14 @@ export const CreateFirstUserClient: React.FC<{
return (
<Form
action={`${serverURL}${apiRoute}/${userSlug}/first-register`}
initialState={initialState}
initialState={{
...initialState,
'confirm-password': {
...initialState['confirm-password'],
valid: initialState['confirm-password']['valid'] || false,
value: initialState['confirm-password']['value'] || '',
},
}}
method="POST"
onChange={[onChange]}
onSuccess={handleFirstRegister}

View File

@@ -15,6 +15,7 @@ type Args = {
locale?: Locale
payload: Payload
req?: PayloadRequest
segments?: string[]
user?: TypedUser
}
@@ -25,12 +26,15 @@ export const getDocumentData = async ({
locale,
payload,
req,
segments,
user,
}: Args): Promise<null | Record<string, unknown> | TypeWithID> => {
const id = sanitizeID(idArg)
let resolvedData: Record<string, unknown> | TypeWithID = null
const { transactionID, ...rest } = req
const isTrashedDoc = segments?.[2] === 'trash' && typeof segments?.[3] === 'string' // id exists at segment 3
try {
if (collectionSlug && id) {
resolvedData = await payload.findByID({
@@ -44,6 +48,7 @@ export const getDocumentData = async ({
req: {
...rest,
},
trash: isTrashedDoc ? true : false,
user,
})
}

View File

@@ -113,7 +113,13 @@ export const getDocumentView = ({
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/<custom-segment>
// --> /collections/:collectionSlug/trash/:id
case 4: {
// --> /collections/:collectionSlug/trash/:id
if (segment3 === 'trash' && segment4) {
View = getCustomViewByKey(views, 'default') || DefaultEditView
break
}
switch (segment4) {
// --> /collections/:collectionSlug/:id/api
case 'api': {
@@ -167,18 +173,86 @@ export const getDocumentView = ({
break
}
// --> /collections/:collectionSlug/trash/:id/api
// --> /collections/:collectionSlug/trash/:id/versions
// --> /collections/:collectionSlug/trash/:id/<custom-segment>
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/<custom-segment>/<custom-segment>
default: {
// --> /collections/:collectionSlug/:id/versions/:version
if (segment4 === 'versions') {
case 5: {
// --> /collections/:slug/trash/:id/api
if (segment3 === 'trash') {
switch (segment5) {
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
View = getCustomViewByKey(views, 'api') || DefaultAPIView
}
break
}
// --> /collections/:slug/trash/:id/versions
case 'versions': {
if (docPermissions?.readVersions) {
View = getCustomViewByKey(views, 'versions') || DefaultVersionsView
} else {
View = UnauthorizedViewWithGutter
}
break
}
default: {
View = getCustomViewByKey(views, 'default') || DefaultEditView
break
}
}
// --> /collections/:collectionSlug/:id/versions/:version
} else if (segment4 === 'versions') {
if (docPermissions?.readVersions) {
View = getCustomViewByKey(views, 'version') || DefaultVersionView
} else {
View = UnauthorizedViewWithGutter
}
} else {
// --> /collections/:collectionSlug/:id/<custom-segment>/<custom-segment>
// --> /collections/:collectionSlug/:id/<custom>/<custom>
const baseRoute = [
adminRoute !== '/' && adminRoute,
collectionEntity,
collectionSlug,
segment3,
]
.filter(Boolean)
.join('/')
const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments]
.filter(Boolean)
.join('/')
const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
if (customViewKey) {
viewKey = customViewKey
View = CustomViewComponent
}
}
break
}
// --> /collections/:collectionSlug/trash/:id/versions/:version
// --> /collections/:collectionSlug/:id/<custom>/<custom>/<custom...>
default: {
// --> /collections/:collectionSlug/trash/:id/versions/:version
const isTrashedVersionView = segment3 === 'trash' && segment5 === 'versions'
if (isTrashedVersionView) {
if (docPermissions?.readVersions) {
View = getCustomViewByKey(views, 'version') || DefaultVersionView
} else {
View = UnauthorizedViewWithGutter
}
} else {
// --> /collections/:collectionSlug/:id/<custom>/<custom>/<custom...>
const baseRoute = [
adminRoute !== '/' && adminRoute,
collectionEntity,

View File

@@ -15,6 +15,7 @@ export type GenerateEditViewMetadata = (
args: {
collectionConfig?: null | SanitizedCollectionConfig
globalConfig?: null | SanitizedGlobalConfig
isReadOnly?: boolean
view?: keyof EditConfig
} & Parameters<GenerateViewMetadata>[0],
) => Promise<Metadata>
@@ -42,6 +43,11 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
fn = generateEditViewMetadata
}
// `/collections/:collection/trash/:id`
if (segments.length === 4 && segments[2] === 'trash') {
fn = (args) => generateEditViewMetadata({ ...args, isReadOnly: true })
}
// `/:collection/:id/:view`
if (params.segments.length === 4) {
switch (params.segments[3]) {
@@ -69,6 +75,25 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
break
}
}
// `/collections/:collection/trash/:id/:view`
if (segments.length === 5 && segments[2] === 'trash') {
switch (segments[4]) {
case 'api':
fn = generateAPIViewMetadata
break
case 'versions':
fn = generateVersionsViewMetadata
break
default:
break
}
}
// `/collections/:collection/trash/:id/versions/:versionID`
if (segments.length === 6 && segments[2] === 'trash' && segments[4] === 'versions') {
fn = generateVersionViewMetadata
}
}
if (isGlobal) {

View File

@@ -65,6 +65,7 @@ export const renderDocument = async ({
redirectAfterCreate,
redirectAfterDelete,
redirectAfterDuplicate,
redirectAfterRestore,
searchParams,
versions,
viewType,
@@ -74,6 +75,7 @@ export const renderDocument = async ({
readonly redirectAfterCreate?: boolean
readonly redirectAfterDelete?: boolean
readonly redirectAfterDuplicate?: boolean
readonly redirectAfterRestore?: boolean
versions?: RenderDocumentVersionsProperties
} & AdminViewServerProps): Promise<{
data: Data
@@ -108,16 +110,18 @@ export const renderDocument = async ({
// Fetch the doc required for the view
let doc =
initialData ||
(await getDocumentData({
id: idFromArgs,
collectionSlug,
globalSlug,
locale,
payload,
req,
user,
}))
!idFromArgs && !globalSlug
? initialData || null
: await getDocumentData({
id: idFromArgs,
collectionSlug,
globalSlug,
locale,
payload,
req,
segments,
user,
})
if (isEditing && !doc) {
// If it's a collection document that doesn't exist, redirect to collection list
@@ -134,6 +138,8 @@ export const renderDocument = async ({
}
}
const isTrashedDoc = typeof doc?.deletedAt === 'string'
const [
docPreferences,
{ docPermissions, hasPublishPermission, hasSavePermission },
@@ -202,6 +208,7 @@ export const renderDocument = async ({
globalSlug,
locale: locale?.code,
operation,
readOnly: isTrashedDoc,
renderAllFields: true,
req,
schemaPath: collectionSlug || globalSlug,
@@ -389,12 +396,14 @@ export const renderDocument = async ({
initialState={formState}
isEditing={isEditing}
isLocked={isLocked}
isTrashed={isTrashedDoc}
key={locale?.code}
lastUpdateTime={lastUpdateTime}
mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved}
redirectAfterCreate={redirectAfterCreate}
redirectAfterDelete={redirectAfterDelete}
redirectAfterDuplicate={redirectAfterDuplicate}
redirectAfterRestore={redirectAfterRestore}
unpublishedVersionCount={unpublishedVersionCount}
versionCount={versionCount}
>
@@ -408,9 +417,8 @@ export const renderDocument = async ({
<DocumentHeader
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
)}
<HydrateAuthProvider permissions={permissions} />

View File

@@ -16,6 +16,7 @@ export const generateEditViewMetadata: GenerateEditViewMetadata = async ({
globalConfig,
i18n,
isEditing,
isReadOnly = false,
view = 'default',
}): Promise<Metadata> => {
const { t } = i18n
@@ -26,11 +27,17 @@ export const generateEditViewMetadata: GenerateEditViewMetadata = async ({
? getTranslation(globalConfig.label, i18n)
: ''
const verb = isReadOnly
? t('general:viewing')
: isEditing
? t('general:editing')
: t('general:creating')
const metaToUse: MetaConfig = {
...(config.admin.meta || {}),
description: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`,
description: `${verb} - ${entityLabel}`,
keywords: `${entityLabel}, Payload, CMS`,
title: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`,
title: `${verb} - ${entityLabel}`,
}
const ogToUse: MetaConfig['openGraph'] = {

View File

@@ -0,0 +1,208 @@
import type {
ClientConfig,
Column,
ListQuery,
PaginatedDocs,
PayloadRequest,
SanitizedCollectionConfig,
ViewTypes,
Where,
} from 'payload'
import { renderTable } from '@payloadcms/ui/rsc'
import { formatDate } from '@payloadcms/ui/shared'
import { flattenAllFields } from 'payload'
export const handleGroupBy = async ({
clientConfig,
collectionConfig,
collectionSlug,
columns,
customCellProps,
drawerSlug,
enableRowSelections,
query,
req,
trash = false,
user,
viewType,
where: whereWithMergedSearch,
}: {
clientConfig: ClientConfig
collectionConfig: SanitizedCollectionConfig
collectionSlug: string
columns: any[]
customCellProps?: Record<string, any>
drawerSlug?: string
enableRowSelections?: boolean
query?: ListQuery
req: PayloadRequest
trash?: boolean
user: any
viewType?: ViewTypes
where: Where
}): Promise<{
columnState: Column[]
data: PaginatedDocs
Table: null | React.ReactNode | React.ReactNode[]
}> => {
let Table: React.ReactNode | React.ReactNode[] = null
let columnState: Column[]
const dataByGroup: Record<string, PaginatedDocs> = {}
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
// NOTE: is there a faster/better way to do this?
const flattenedFields = flattenAllFields({ fields: collectionConfig.fields })
const groupByFieldPath = query.groupBy.replace(/^-/, '')
const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath)
const relationshipConfig =
groupByField?.type === 'relationship'
? clientConfig.collections.find((c) => c.slug === groupByField.relationTo)
: undefined
let populate
if (groupByField?.type === 'relationship' && groupByField.relationTo) {
const relationTo =
typeof groupByField.relationTo === 'string'
? [groupByField.relationTo]
: groupByField.relationTo
if (Array.isArray(relationTo)) {
relationTo.forEach((rel) => {
if (!populate) {
populate = {}
}
populate[rel] = { [relationshipConfig?.admin.useAsTitle || 'id']: true }
})
}
}
const distinct = await req.payload.findDistinct({
collection: collectionSlug,
depth: 1,
field: groupByFieldPath,
limit: query?.limit ? Number(query.limit) : undefined,
locale: req.locale,
overrideAccess: false,
page: query?.page ? Number(query.page) : undefined,
populate,
req,
sort: query?.groupBy,
trash,
where: whereWithMergedSearch,
})
const data = {
...distinct,
docs: distinct.values?.map(() => ({})) || [],
values: undefined,
}
await Promise.all(
distinct.values.map(async (distinctValue, i) => {
const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath]
const valueOrRelationshipID =
groupByField?.type === 'relationship' &&
potentiallyPopulatedRelationship &&
typeof potentiallyPopulatedRelationship === 'object' &&
'id' in potentiallyPopulatedRelationship
? potentiallyPopulatedRelationship.id
: potentiallyPopulatedRelationship
const groupData = await req.payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
limit: query?.queryByGroup?.[valueOrRelationshipID]?.limit
? Number(query.queryByGroup[valueOrRelationshipID].limit)
: undefined,
locale: req.locale,
overrideAccess: false,
page: query?.queryByGroup?.[valueOrRelationshipID]?.page
? Number(query.queryByGroup[valueOrRelationshipID].page)
: undefined,
req,
// 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 || {}),
[groupByFieldPath]: {
equals: valueOrRelationshipID,
},
},
})
let heading = valueOrRelationshipID || req.i18n.t('general:noValue')
if (
groupByField?.type === 'relationship' &&
potentiallyPopulatedRelationship &&
typeof potentiallyPopulatedRelationship === 'object'
) {
heading =
potentiallyPopulatedRelationship[relationshipConfig.admin.useAsTitle || 'id'] ||
valueOrRelationshipID
}
if (groupByField.type === 'date') {
heading = formatDate({
date: String(heading),
i18n: req.i18n,
pattern: clientConfig.admin.dateFormat,
})
}
if (groupData.docs && groupData.docs.length > 0) {
const { columnState: newColumnState, Table: NewTable } = renderTable({
clientCollectionConfig,
collectionConfig,
columns,
customCellProps,
data: groupData,
drawerSlug,
enableRowSelections,
groupByFieldPath,
groupByValue: valueOrRelationshipID,
heading,
i18n: req.i18n,
key: `table-${valueOrRelationshipID}`,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
useAsTitle: collectionConfig.admin.useAsTitle,
viewType,
})
// Only need to set `columnState` once, using the first table's column state
// This will avoid needing to generate column state explicitly for root context that wraps all tables
if (!columnState) {
columnState = newColumnState
}
if (!Table) {
Table = []
}
dataByGroup[valueOrRelationshipID] = groupData
;(Table as Array<React.ReactNode>)[i] = NewTable
}
}),
)
return {
columnState,
data,
Table,
}
}

View File

@@ -1,18 +1,19 @@
import type {
AdminViewServerProps,
CollectionPreferences,
ColumnPreference,
ListQuery,
ListViewClientProps,
ListViewServerPropsOnly,
QueryPreset,
SanitizedCollectionPermission,
} from 'payload'
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc'
import { notFound } from 'next/navigation.js'
import {
type AdminViewServerProps,
type CollectionPreferences,
type Column,
type ColumnPreference,
type ListQuery,
type ListViewClientProps,
type ListViewServerPropsOnly,
type PaginatedDocs,
type QueryPreset,
type SanitizedCollectionPermission,
} from 'payload'
import {
combineWhereConstraints,
formatAdminURL,
@@ -24,6 +25,7 @@ import {
import React, { Fragment } from 'react'
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { handleGroupBy } from './handleGroupBy.js'
import { renderListViewSlots } from './renderListViewSlots.js'
import { resolveAllFilterOptions } from './resolveAllFilterOptions.js'
@@ -38,6 +40,10 @@ type RenderListViewArgs = {
query: ListQuery
redirectAfterDelete?: boolean
redirectAfterDuplicate?: boolean
/**
* @experimental This prop is subject to change in future releases.
*/
trash?: boolean
} & AdminViewServerProps
/**
@@ -64,6 +70,8 @@ export const renderListView = async (
params,
query: queryFromArgs,
searchParams,
trash,
viewType,
} = args
const {
@@ -74,7 +82,6 @@ export const renderListView = async (
req,
req: {
i18n,
locale,
payload,
payload: { config },
query: queryFromReq,
@@ -91,11 +98,17 @@ export const renderListView = async (
const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns)
query.queryByGroup =
query?.queryByGroup && typeof query.queryByGroup === 'string'
? JSON.parse(query.queryByGroup)
: query?.queryByGroup
const collectionPreferences = await upsertPreferences<CollectionPreferences>({
key: `collection-${collectionSlug}`,
req,
value: {
columns: columnsFromQuery,
groupBy: query?.groupBy,
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
preset: query?.preset,
sort: query?.sort as string,
@@ -112,6 +125,8 @@ export const renderListView = async (
collectionPreferences?.sort ||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined)
query.groupBy = collectionPreferences?.groupBy
query.columns = transformColumnsToSearchParams(collectionPreferences?.columns || [])
const {
@@ -137,6 +152,25 @@ export const renderListView = async (
let queryPreset: QueryPreset | undefined
let queryPresetPermissions: SanitizedCollectionPermission | undefined
let whereWithMergedSearch = mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: combineWhereConstraints([query?.where, baseListFilter]),
})
if (trash === true) {
whereWithMergedSearch = {
and: [
whereWithMergedSearch,
{
deletedAt: {
exists: true,
},
},
],
}
}
if (collectionPreferences?.preset) {
try {
queryPreset = (await payload.findByID({
@@ -160,41 +194,82 @@ export const renderListView = async (
}
}
const data = await payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
let Table: React.ReactNode | React.ReactNode[] = null
let columnState: Column[] = []
let data: PaginatedDocs = {
// no results default
docs: [],
hasNextPage: false,
hasPrevPage: false,
limit: query.limit,
locale,
overrideAccess: false,
page: query.page,
req,
sort: query.sort,
user,
where: mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: combineWhereConstraints([query?.where, baseListFilter]),
}),
})
nextPage: null,
page: 1,
pagingCounter: 0,
prevPage: null,
totalDocs: 0,
totalPages: 0,
}
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
const { columnState, Table } = renderTable({
clientCollectionConfig,
collectionConfig,
columns: collectionPreferences?.columns,
customCellProps,
docs: data.docs,
drawerSlug,
enableRowSelections,
i18n: req.i18n,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload,
useAsTitle: collectionConfig.admin.useAsTitle,
})
try {
if (collectionConfig.admin.groupBy && query.groupBy) {
;({ columnState, data, Table } = await handleGroupBy({
clientConfig,
collectionConfig,
collectionSlug,
columns: collectionPreferences?.columns,
customCellProps,
drawerSlug,
enableRowSelections,
query,
req,
trash,
user,
viewType,
where: whereWithMergedSearch,
}))
} else {
data = await req.payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
limit: query?.limit ? Number(query.limit) : undefined,
locale: req.locale,
overrideAccess: false,
page: query?.page ? Number(query.page) : undefined,
req,
sort: query?.sort,
trash,
user,
where: whereWithMergedSearch,
})
;({ columnState, Table } = renderTable({
clientCollectionConfig: clientConfig.collections.find((c) => c.slug === collectionSlug),
collectionConfig,
columns: collectionPreferences?.columns,
customCellProps,
data,
drawerSlug,
enableRowSelections,
i18n: req.i18n,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
useAsTitle: collectionConfig.admin.useAsTitle,
viewType,
}))
}
} catch (err) {
if (err.name !== 'QueryError') {
// QueryErrors are expected when a user filters by a field they do not have access to
req.payload.logger.error({
err,
msg: `There was an error fetching the list view data for collection ${collectionSlug}`,
})
throw err
}
}
const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)
@@ -214,6 +289,7 @@ export const renderListView = async (
})
const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create
const hasDeletePermission = permissions?.collections?.[collectionSlug]?.delete
// Check if there's a notFound query parameter (document ID that wasn't found)
const notFoundDocId = typeof searchParams?.notFound === 'string' ? searchParams.notFound : null
@@ -237,6 +313,7 @@ export const renderListView = async (
clientProps: {
collectionSlug,
hasCreatePermission,
hasDeletePermission,
newDocumentURL,
},
collectionConfig,
@@ -249,6 +326,7 @@ export const renderListView = async (
const isInDrawer = Boolean(drawerSlug)
// Needed to prevent: Only plain objects can be passed to Client Components from Server Components. Objects with toJSON methods are not supported. Convert it manually to a simple value before passing it to props.
// Is there a way to avoid this? The `where` object is already seemingly plain, but is not bc it originates from the params.
query.where = query?.where ? JSON.parse(JSON.stringify(query?.where || {})) : undefined
return {
@@ -272,6 +350,7 @@ export const renderListView = async (
disableQueryPresets,
enableRowSelections,
hasCreatePermission,
hasDeletePermission,
listPreferences: collectionPreferences,
newDocumentURL,
queryPreset,
@@ -279,6 +358,7 @@ export const renderListView = async (
renderedFilters,
resolvedFilterOptions,
Table,
viewType,
} satisfies ListViewClientProps,
Component: collectionConfig?.admin?.components?.views?.list?.Component,
Fallback: DefaultListView,

View File

@@ -17,6 +17,7 @@ import type { initPage } from '../../utilities/initPage/index.js'
import { Account } from '../Account/index.js'
import { BrowseByFolder } from '../BrowseByFolder/index.js'
import { CollectionFolderView } from '../CollectionFolders/index.js'
import { TrashView } from '../CollectionTrash/index.js'
import { CreateFirstUserView } from '../CreateFirstUser/index.js'
import { Dashboard } from '../Dashboard/index.js'
import { Document as DocumentView } from '../Document/index.js'
@@ -107,7 +108,7 @@ export const getRouteData = ({
searchParams,
}
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive] = segments
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments
const isGlobal = segmentOne === 'globals'
const isCollection = segmentOne === 'collections'
@@ -272,7 +273,50 @@ export const getRouteData = ({
viewType = 'verify'
} else if (isCollection && matchedCollection) {
initPageOptions.routeParams.collection = matchedCollection.slug
if (config.folders && segmentThree === config.folders.slug && matchedCollection.folders) {
if (segmentThree === 'trash' && typeof segmentFour === 'string') {
// --> /collections/:collectionSlug/trash/:id (read-only)
// --> /collections/:collectionSlug/trash/:id/api
// --> /collections/:collectionSlug/trash/:id/preview
// --> /collections/:collectionSlug/trash/:id/versions
// --> /collections/:collectionSlug/trash/:id/versions/:versionID
initPageOptions.routeParams.id = segmentFour
initPageOptions.routeParams.versionID = segmentSix
ViewToRender = {
Component: DocumentView,
}
templateClassName = `collection-default-edit`
templateType = 'default'
const viewInfo = getDocumentViewInfo([segmentFive, segmentSix])
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedCollection,
serverProps,
viewKeyArg: documentSubViewType,
})
} else if (segmentThree === 'trash') {
// --> /collections/:collectionSlug/trash
ViewToRender = {
Component: TrashView,
}
templateClassName = `${segmentTwo}-trash`
templateType = 'default'
viewType = 'trash'
serverProps.viewActions = serverProps.viewActions.concat(
matchedCollection.admin.components?.views?.list?.actions ?? [],
)
} else if (
config.folders &&
segmentThree === config.folders.slug &&
matchedCollection.folders
) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID

View File

@@ -5,6 +5,7 @@ import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { generateAccountViewMetadata } from '../Account/metadata.js'
import { generateBrowseByFolderMetadata } from '../BrowseByFolder/metadata.js'
import { generateCollectionFolderMetadata } from '../CollectionFolders/metadata.js'
import { generateCollectionTrashMetadata } from '../CollectionTrash/metadata.js'
import { generateCreateFirstUserViewMetadata } from '../CreateFirstUser/metadata.js'
import { generateDashboardViewMetadata } from '../Dashboard/metadata.js'
import { generateDocumentViewMetadata } from '../Document/metadata.js'
@@ -129,7 +130,16 @@ export const generatePageMetadata = async ({
// --> /:collectionSlug/verify/:token
meta = await generateVerifyViewMetadata({ config, i18n })
} else if (isCollection) {
if (config.folders && segmentThree === config.folders.slug) {
if (segmentThree === 'trash' && segments.length === 3 && collectionConfig) {
// Collection Trash Views
// --> /collections/:collectionSlug/trash
meta = await generateCollectionTrashMetadata({
collectionConfig,
config,
i18n,
params,
})
} else if (config.folders && segmentThree === config.folders.slug) {
if (folderCollectionSlugs.includes(collectionConfig.slug)) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
@@ -147,6 +157,7 @@ export const generatePageMetadata = async ({
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/trash/:id
meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params })
}
} else if (isGlobal) {

View File

@@ -12,13 +12,15 @@ export const SetStepNav: React.FC<{
readonly collectionConfig?: ClientCollectionConfig
readonly globalConfig?: ClientGlobalConfig
readonly id?: number | string
readonly isTrashed?: boolean
versionToCreatedAtFormatted?: string
versionToID?: string
versionToUseAsTitle?: string
versionToUseAsTitle?: Record<string, string> | string
}> = ({
id,
collectionConfig,
globalConfig,
isTrashed,
versionToCreatedAtFormatted,
versionToID,
versionToUseAsTitle,
@@ -52,10 +54,14 @@ export const SetStepNav: React.FC<{
? versionToUseAsTitle?.[locale.code] || docLabel
: versionToUseAsTitle
} else if (useAsTitle === 'id') {
docLabel = versionToID
docLabel = String(id)
}
setStepNav([
const docBasePath: `/${string}` = isTrashed
? `/collections/${collectionSlug}/trash/${id}`
: `/collections/${collectionSlug}/${id}`
const nav = [
{
label: getTranslation(pluralLabel, i18n),
url: formatAdminURL({
@@ -63,24 +69,40 @@ export const SetStepNav: React.FC<{
path: `/collections/${collectionSlug}`,
}),
},
]
if (isTrashed) {
nav.push({
label: t('general:trash'),
url: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/trash`,
}),
})
}
nav.push(
{
label: docLabel,
url: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${id}`,
path: docBasePath,
}),
},
{
label: 'Versions',
url: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${id}/versions`,
path: `${docBasePath}/versions`,
}),
},
{
label: versionToCreatedAtFormatted,
url: undefined,
},
])
)
setStepNav(nav)
return
}
@@ -111,6 +133,7 @@ export const SetStepNav: React.FC<{
config,
setStepNav,
id,
isTrashed,
locale,
t,
i18n,

View File

@@ -67,7 +67,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
}
}, [code, config.localization, selectedLocalesFromProps])
const { id: originalDocID, collectionSlug, globalSlug } = useDocumentInfo()
const { id: originalDocID, collectionSlug, globalSlug, isTrashed } = useDocumentInfo()
const { startRouteTransition } = useRouteTransition()
const { collectionConfig, globalConfig } = useMemo(() => {
@@ -252,7 +252,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
</div>
<div className={`${baseClass}__version-to-version`}>
{VersionToCreatedAtLabel}
{canUpdate && (
{canUpdate && !isTrashed && (
<Restore
className={`${baseClass}__restore`}
collectionConfig={collectionConfig}
@@ -272,6 +272,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
collectionConfig={collectionConfig}
globalConfig={globalConfig}
id={originalDocID}
isTrashed={isTrashed}
versionToCreatedAtFormatted={versionToCreatedAtFormatted}
versionToID={versionToID}
versionToUseAsTitle={versionToUseAsTitle}

View File

@@ -18,12 +18,12 @@ export const generateLabelFromValue = ({
value: PopulatedRelationshipValue
}): string => {
let relatedDoc: TypeWithID
let relationTo: string = field.relationTo as string
let valueToReturn: string = ''
const relationTo: string = 'relationTo' in value ? value.relationTo : (field.relationTo as string)
if (typeof value === 'object' && 'relationTo' in value) {
relatedDoc = value.value
relationTo = value.relationTo
} else {
// Non-polymorphic relationship
relatedDoc = value

View File

@@ -3,6 +3,7 @@ import {
Drawer,
LoadingOverlay,
toast,
useDocumentInfo,
useEditDepth,
useModal,
useServerFunctions,
@@ -30,6 +31,7 @@ export const VersionDrawerContent: React.FC<{
globalSlug?: string
}> = (props) => {
const { collectionSlug, docID, drawerSlug, globalSlug } = props
const { isTrashed } = useDocumentInfo()
const { closeModal } = useModal()
const searchParams = useSearchParams()
const prevSearchParams = useRef(searchParams)
@@ -58,6 +60,7 @@ export const VersionDrawerContent: React.FC<{
segments: [
isGlobal ? 'globals' : 'collections',
entitySlug,
...(isTrashed ? ['trash'] : []),
isGlobal ? undefined : String(docID),
'versions',
].filter(Boolean),
@@ -84,7 +87,16 @@ export const VersionDrawerContent: React.FC<{
void fetchDocumentView()
},
[closeModal, collectionSlug, globalSlug, drawerSlug, renderDocument, searchParams, t],
[
closeModal,
collectionSlug,
drawerSlug,
globalSlug,
isTrashed,
renderDocument,
searchParams,
t,
],
)
useEffect(() => {

View File

@@ -411,6 +411,11 @@ export async function VersionView(props: DocumentViewServerProps) {
})
}
const useAsTitleFieldName = collectionConfig?.admin?.useAsTitle || 'id'
const versionToUseAsTitle =
useAsTitleFieldName === 'id'
? String(versionTo.parent)
: versionTo.version?.[useAsTitleFieldName]
return (
<DefaultVersionView
canUpdate={docPermissions?.update}
@@ -425,7 +430,7 @@ export async function VersionView(props: DocumentViewServerProps) {
VersionToCreatedAtLabel={formatPill({ doc: versionTo, labelStyle: 'pill' })}
versionToID={versionTo.id}
versionToStatus={versionTo.version?._status}
versionToUseAsTitle={versionTo[collectionConfig?.admin?.useAsTitle || 'id']}
versionToUseAsTitle={versionToUseAsTitle}
/>
)
}

View File

@@ -23,6 +23,7 @@ export const buildVersionColumns = ({
docs,
globalConfig,
i18n: { t },
isTrashed,
latestDraftVersion,
}: {
collectionConfig?: SanitizedCollectionConfig
@@ -35,6 +36,7 @@ export const buildVersionColumns = ({
docs: PaginatedDocs<TypeWithVersion<any>>['docs']
globalConfig?: SanitizedGlobalConfig
i18n: I18n
isTrashed?: boolean
latestDraftVersion?: {
id: number | string
updatedAt: string
@@ -59,6 +61,7 @@ export const buildVersionColumns = ({
collectionSlug={collectionConfig?.slug}
docID={docID}
globalSlug={globalConfig?.slug}
isTrashed={isTrashed}
key={i}
rowData={{
id: doc.id,

View File

@@ -8,6 +8,7 @@ export type CreatedAtCellProps = {
collectionSlug?: string
docID?: number | string
globalSlug?: string
isTrashed?: boolean
rowData?: {
id: number | string
updatedAt: Date | number | string
@@ -18,6 +19,7 @@ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
collectionSlug,
docID,
globalSlug,
isTrashed,
rowData: { id, updatedAt } = {},
}) => {
const {
@@ -29,12 +31,14 @@ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
const { i18n } = useTranslation()
const trashedDocPrefix = isTrashed ? 'trash/' : ''
let to: string
if (collectionSlug) {
to = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${docID}/versions/${id}`,
path: `/collections/${collectionSlug}/${trashedDocPrefix}${docID}/versions/${id}`,
})
}

View File

@@ -27,6 +27,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
user,
},
},
routeSegments: segments,
searchParams: { limit, page, sort },
versions: { disableGutter = false, useVersionDrawerCreatedAtCell = false } = {},
} = props
@@ -36,6 +37,8 @@ export async function VersionsView(props: DocumentViewServerProps) {
const collectionSlug = collectionConfig?.slug
const globalSlug = globalConfig?.slug
const isTrashed = segments[2] === 'trash'
const {
localization,
routes: { api: apiRoute },
@@ -124,6 +127,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
docs: versionsData?.docs,
globalConfig,
i18n,
isTrashed,
latestDraftVersion,
})
@@ -140,6 +144,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
collectionSlug={collectionSlug}
globalSlug={globalSlug}
id={id}
isTrashed={isTrashed}
pluralLabel={pluralLabel}
useAsTitle={collectionConfig?.admin?.useAsTitle || globalSlug}
view={i18n.t('version:versions')}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
"version": "3.48.0",
"version": "3.49.1",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.48.0",
"version": "3.49.1",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -22,6 +22,7 @@ import type {
UploadFieldClient,
} from '../../fields/config/types.js'
import type { Payload } from '../../types/index.js'
import type { ViewTypes } from '../types.js'
export type RowData = Record<string, any>
@@ -82,6 +83,7 @@ export type DefaultCellComponentProps<
rowData: RowData
}) => void
rowData: RowData
viewType?: ViewTypes
}
export type DefaultServerCellComponentProps<

View File

@@ -68,6 +68,9 @@ export type FieldPaths = {
path: string
}
/**
* TODO: This should be renamed to `FieldComponentServerProps` or similar
*/
export type ServerComponentProps = {
clientField: ClientFieldWithOptionalType
clientFieldSchemaMap: ClientFieldSchemaMap

View File

@@ -113,6 +113,7 @@ export type BuildFormStateArgs = {
*/
mockRSCs?: boolean
operation?: 'create' | 'update'
readOnly?: boolean
/*
If true, will render field components within their state object
*/

View File

@@ -45,9 +45,15 @@ export type ListQuery = {
* Use `transformColumnsToPreferences` and `transformColumnsToSearchParams` to convert it back and forth
*/
columns?: ColumnsFromURL
/*
* A string representing the field to group by, e.g. `category`
* A leading hyphen represents descending order, e.g. `-category`
*/
groupBy?: string
limit?: number
page?: number
preset?: number | string
queryByGroup?: Record<string, ListQuery>
/*
When provided, is automatically injected into the `where` object
*/
@@ -59,6 +65,10 @@ export type ListQuery = {
export type BuildTableStateArgs = {
collectionSlug: string | string[]
columns?: ColumnPreference[]
data?: PaginatedDocs
/**
* @deprecated Use `data` instead
*/
docs?: PaginatedDocs['docs']
enableRowSelections?: boolean
orderableFieldName: string

View File

@@ -2,6 +2,7 @@ import type { SanitizedPermissions } from '../../auth/types.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { PayloadComponent, SanitizedConfig, ServerProps } from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Data, DocumentSlots, FormState } from '../types.js'
import type { InitPageResult, ViewTypes } from './index.js'
@@ -50,6 +51,7 @@ export type DocumentTabServerPropsOnly = {
readonly collectionConfig?: SanitizedCollectionConfig
readonly globalConfig?: SanitizedGlobalConfig
readonly permissions: SanitizedPermissions
readonly req: PayloadRequest
} & ServerProps
export type DocumentTabClientProps = {
@@ -60,9 +62,13 @@ export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerP
export type DocumentTabCondition = (args: {
collectionConfig: SanitizedCollectionConfig
/**
* @deprecated: Use `req.payload.config` instead. This will be removed in v4.
*/
config: SanitizedConfig
globalConfig: SanitizedGlobalConfig
permissions: SanitizedPermissions
req: PayloadRequest
}) => boolean
// Everything is optional because we merge in the defaults

View File

@@ -53,6 +53,7 @@ export type AdminViewServerPropsOnly = {
readonly redirectAfterCreate?: boolean
readonly redirectAfterDelete?: boolean
readonly redirectAfterDuplicate?: boolean
readonly redirectAfterRestore?: boolean
} & ServerProps
export type AdminViewServerProps = AdminViewClientProps & AdminViewServerPropsOnly
@@ -92,6 +93,7 @@ export type ViewTypes =
| 'folders'
| 'list'
| 'reset'
| 'trash'
| 'verify'
| 'version'

View File

@@ -8,7 +8,7 @@ import type { CollectionPreferences } from '../../preferences/types.js'
import type { QueryPreset } from '../../query-presets/types.js'
import type { ResolvedFilterOptions } from '../../types/index.js'
import type { Column } from '../elements/Table.js'
import type { Data } from '../types.js'
import type { Data, ViewTypes } from '../types.js'
export type ListViewSlots = {
AfterList?: React.ReactNode
@@ -17,7 +17,7 @@ export type ListViewSlots = {
BeforeListTable?: React.ReactNode
Description?: React.ReactNode
listMenuItems?: React.ReactNode[]
Table: React.ReactNode
Table: React.ReactNode | React.ReactNode[]
}
/**
@@ -45,6 +45,7 @@ export type ListViewClientProps = {
disableQueryPresets?: boolean
enableRowSelections?: boolean
hasCreatePermission: boolean
hasDeletePermission?: boolean
/**
* @deprecated
*/
@@ -58,11 +59,13 @@ export type ListViewClientProps = {
queryPresetPermissions?: SanitizedCollectionPermission
renderedFilters?: Map<string, React.ReactNode>
resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
viewType: ViewTypes
} & ListViewSlots
export type ListViewSlotSharedClientProps = {
collectionSlug: SanitizedCollectionConfig['slug']
hasCreatePermission: boolean
hasDeletePermission?: boolean
newDocumentURL: string
}

View File

@@ -1,6 +1,6 @@
export const isUserLocked = (date: number): boolean => {
export const isUserLocked = (date: Date): boolean => {
if (!date) {
return false
}
return date > Date.now()
return date.getTime() > Date.now()
}

View File

@@ -12,6 +12,7 @@ import type { PayloadRequest, Where } from '../../types/index.js'
import { buildAfterOperation } from '../../collections/operations/utils.js'
import { APIError } from '../../errors/index.js'
import { Forbidden } from '../../index.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { initTransaction } from '../../utilities/initTransaction.js'
@@ -123,6 +124,13 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(
}
}
// Exclude trashed users unless `trash: true`
whereConstraint = appendNonTrashedFilter({
enableTrash: collectionConfig.trash,
trash: false,
where: whereConstraint,
})
let user = await payload.db.findOne<UserDoc>({
collection: collectionConfig.slug,
req,

View File

@@ -1,4 +1,6 @@
import type { PayloadRequest } from '../../types/index.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
export const initOperation = async (args: {
collection: string
@@ -6,9 +8,19 @@ export const initOperation = async (args: {
}): Promise<boolean> => {
const { collection: slug, req } = args
const collectionConfig = req.payload.config.collections?.find((c) => c.slug === slug)
// Exclude trashed documents unless `trash: true`
const where: Where = appendNonTrashedFilter({
enableTrash: Boolean(collectionConfig?.trash),
trash: false,
where: {},
})
const doc = await req.payload.db.findOne({
collection: slug,
req,
where,
})
return !!doc

View File

@@ -22,6 +22,7 @@ export type Options<TSlug extends CollectionSlug> = {
overrideAccess?: boolean
req?: Partial<PayloadRequest>
showHiddenFields?: boolean
trash?: boolean
}
export async function loginLocal<TSlug extends CollectionSlug>(

View File

@@ -17,6 +17,7 @@ import {
} from '../../errors/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { Forbidden } from '../../index.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js'
import { getFieldsToSign } from '../getFieldsToSign.js'
@@ -49,6 +50,11 @@ type CheckLoginPermissionArgs = {
user: any
}
/**
* Throws an error if the user is locked or does not exist.
* This does not check the login attempts, only the lock status. Whoever increments login attempts
* is responsible for locking the user properly, not whoever checks the login permission.
*/
export const checkLoginPermission = ({
loggingInWithUsername,
req,
@@ -58,7 +64,7 @@ export const checkLoginPermission = ({
throw new AuthenticationError(req.t, Boolean(loggingInWithUsername))
}
if (isUserLocked(new Date(user.lockUntil).getTime())) {
if (isUserLocked(new Date(user.lockUntil))) {
throw new LockedAuth(req.t)
}
}
@@ -198,11 +204,18 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
whereConstraint = usernameConstraint
}
let user = await payload.db.findOne<any>({
// Exclude trashed users
whereConstraint = appendNonTrashedFilter({
enableTrash: collectionConfig.trash,
trash: false,
where: whereConstraint,
})
let user = (await payload.db.findOne<TypedUser>({
collection: collectionConfig.slug,
req,
where: whereConstraint,
})
})) as TypedUser
checkLoginPermission({
loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername),
@@ -222,9 +235,16 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
if (maxLoginAttemptsEnabled) {
await incrementLoginAttempts({
collection: collectionConfig,
doc: user,
payload: req.payload,
req,
user,
})
// Re-check login permissions and max attempts after incrementing attempts, in case parallel updates occurred
checkLoginPermission({
loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername),
req,
user,
})
}
@@ -235,6 +255,30 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
throw new UnverifiedEmail({ t: req.t })
}
/*
* Correct password accepted - recheck that the account didn't
* get locked by parallel bad attempts in the meantime.
*/
if (maxLoginAttemptsEnabled) {
const { lockUntil, loginAttempts } = (await payload.db.findOne<TypedUser>({
collection: collectionConfig.slug,
req,
select: {
lockUntil: true,
loginAttempts: true,
},
where: { id: { equals: user.id } },
}))!
user.lockUntil = lockUntil
user.loginAttempts = loginAttempts
checkLoginPermission({
req,
user,
})
}
const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[0] = {
collectionConfig,
email: sanitizedEmail!,

View File

@@ -4,6 +4,7 @@ import type { Collection } from '../../collections/config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import { APIError } from '../../errors/index.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
export type Arguments = {
allSessions?: boolean
@@ -39,17 +40,23 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean>
}
if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) {
const where = appendNonTrashedFilter({
enableTrash: Boolean(collectionConfig.trash),
trash: false,
where: {
id: {
equals: user.id,
},
},
})
const userWithSessions = await req.payload.db.findOne<{
id: number | string
sessions: { id: string }[]
}>({
collection: collectionConfig.slug,
req,
where: {
id: {
equals: user.id,
},
},
where,
})
if (!userWithSessions) {

View File

@@ -1,5 +1,4 @@
import url from 'url'
import { v4 as uuid } from 'uuid'
import type { Collection } from '../../collections/config/types.js'
import type { Document, PayloadRequest } from '../../types/index.js'
@@ -74,11 +73,10 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
const parsedURL = url.parse(args.req.url!)
const isGraphQL = parsedURL.pathname === config.routes.graphQL
const user = await args.req.payload.findByID({
id: args.req.user.id,
collection: args.req.user.collection,
depth: isGraphQL ? 0 : args.collection.config.auth.depth,
req: args.req,
let user = await req.payload.db.findOne<any>({
collection: collectionConfig.slug,
req,
where: { id: { equals: args.req.user.id } },
})
const sid = args.req.user._sid
@@ -88,7 +86,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
throw new Forbidden(args.req.t)
}
const existingSession = user.sessions.find(({ id }) => id === sid)
const existingSession = user.sessions.find(({ id }: { id: number }) => id === sid)
const now = new Date()
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
@@ -106,6 +104,13 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
})
}
user = await req.payload.findByID({
id: user.id,
collection: collectionConfig.slug,
depth: isGraphQL ? 0 : args.collection.config.auth.depth,
req: args.req,
})
if (user) {
user.collection = args.req.user.collection
user._strategy = args.req.user._strategy

View File

@@ -8,6 +8,7 @@ import type { CollectionSlug } from '../../index.js'
import type { PayloadRequest, SelectType } from '../../types/index.js'
import { Forbidden } from '../../errors/index.js'
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
@@ -57,9 +58,16 @@ export const registerFirstUserOperation = async <TSlug extends CollectionSlug>(
req,
})
const where = appendNonTrashedFilter({
enableTrash: Boolean(config.trash),
trash: false,
where: {}, // no initial filter; just exclude trashed docs
})
const doc = await payload.db.findOne({
collection: config.slug,
req,
where,
})
if (doc) {

Some files were not shown because too many files have changed in this diff Show More