Compare commits

...

90 Commits

Author SHA1 Message Date
Jarrod Flesch
6d5cc843a2 fix(db-mongodb): updateOne mutates the data object and does not transform it for read (#13065)
Fixes https://github.com/payloadcms/payload/issues/13045

`updateOne` when returning is `false` mutates the data object for write
operations on the DB, but that causes an issue when using that data
object later on since all of the id's are mutated to objectIDs and never
transformed back into read id's.

This fix ensures that the transform happens even when the result is not
returned.
2025-07-07 14:50:01 -04:00
Jarrod Flesch
34920a7ec0 test: fix tests that rely on remote urls (#13073) 2025-07-07 14:02:55 -04:00
Germán Jabloñski
2650eb7d44 fix(ui): increase timeout for opening list drawer in RelationshipInput (#13031)
As stated in #12529, the setTimeout was defined through trial and error
as it wasn't possible to reproduce the bug with the devtools open and
therefore with the CPU throttled. One user reported still experiencing
the bug.

I'm increasing the timeout to 100ms, which seems acceptable enough to
keep postponing a better fix, considering the bug isn't that critical.

If we find it keeps happening, we'll probably need to investigate the
root cause.
2025-07-07 09:30:09 -04:00
Jessica Rynkar
50c2f8bec2 fix(plugin-redirects): make 'from' field unique to prevent errors in redirect logic (#12964)
### What?
This PR updates the `from` field in `plugin-redirects` to add `unique:
true`.

### Why?
If you create multiple redirects with the same `from` URL — the
application won't know which one to follow, which causes errors and
unpredictable behavior.

### How?
Adds `unique: true` to the plugin injected `from` field.

### Migration Required
This change will require a migration. Projects already using this plugin
will need to:
- Ensure there are no duplicate `from` values in their existing
redirects collection.
- Remove or modify any duplicate entries before applying this update.

Fixes #12959
2025-07-07 11:21:40 +01:00
Jacob Fletcher
f49eeb1a63 fix(next): respect collection-level live preview config (#13036)
Fixes #13035.

We broke collection-level live preview configs in #12860.
2025-07-03 21:47:16 +00:00
Jarrod Flesch
1d9ad6f2f1 fix(ui): change password button is hidden when user has full field access (#12988) 2025-07-03 13:59:22 -04:00
Kendell
30fc7e3012 fix: check hostname of upload url (#13018)
Adds:
```ts
import { lookup } from 'dns/promises'
// ...
const { address } = await lookup(hostname)
// ...
return isSafeIp(address)
```

To ensure that an `ip` address is being verified. Previously, hostnames
were being verified by `isSafeIp`.


Fixes: https://github.com/payloadcms/payload/issues/12876
2025-07-03 10:50:31 -04:00
Elliot DeNolf
1ccd7ef074 chore(release): v3.45.0 [skip ci] 2025-07-03 09:23:23 -04:00
Patrik
34c3a5193b fix(plugin-import-export): pre-scan columns before streaming CSV export (#13009)
### What?

Fixes an issue where only the fields from the first batch of documents
were used to generate CSV column headers during streaming exports.

### Why?

Previously, columns were determined during the first streaming batch. If
a field appeared only in later documents, it was omitted from the CSV
entirely — leading to incomplete exports when fields were sparsely
populated across the dataset.

### How?

- Adds a **pre-scan step** before streaming begins to collect all column
keys across all pages
- Uses this superset of keys to define the final CSV header
- Ensures every row is padded to match the full column set

This matches the behavior of non-streamed exports and guarantees that
the streamed CSV output includes all relevant fields, regardless of when
they appear in pagination.
2025-07-03 08:53:02 -04:00
Sasha
81532cb9c9 fix(db-mongodb): nested sorting by ID (#13016)
Fixes sorting when the `sort` path contains a relationship and ends with
`id`, for example `sort: 'post.category.id'`.
2025-07-03 08:51:45 -04:00
Sebastian Blank
f70c6fe3e7 fix(templates): wrong link in demo content (custom components) (#13024)
### What?

The "custom component" link in the dashboard of the website demo is
wrong:

![image](https://github.com/user-attachments/assets/ee716a87-c515-4561-932d-f1c1fcccfd5e)
2025-07-03 12:07:19 +00:00
Alessio Gravili
e6b664284f chore: fix payload bundle script (#13022)
This fixes the payload bundle script. While not run by default, it's
useful for checking the payload bundle size by manually running `cd
packages/payload && node bundle.js`.
2025-07-03 04:37:44 -07:00
Alessio Gravili
fafaa04e1a fix(drizzle): ensure updateOne does not create new document if where query has no results (#12991)
Previously, `db.updateOne` calls with `where` queries that lead to no
results would create new rows on drizzle. Essentially, `db.updateOne`
behaved like `db.upsertOne` on drizzle
2025-07-02 13:56:59 -07:00
Germán Jabloñski
babcd599da fix(ui): save nested richtext inside inlineBlock (#12773)
Removing the `setTimeout` not only doesn't break any tests, but it also
fixes the linked issue.

The long comment above the if statement was added in
https://github.com/payloadcms/payload/pull/5460 and explains why the if
statement is necessary GIVEN the existence of the `setTimeout`, but the
`setTimeout` was introduced [earlier because the button apparently
didn't work](https://github.com/payloadcms/payload/issues/1414).

It seems to work now without the `setTimeout`, because otherwise the
tests wouldn't even pass. I also tested it manually, and it works fine.


Fixes #12687
2025-07-02 19:43:48 +00:00
Jessica Rynkar
ac19b78968 style(richtext-lexical): ensure error state is shown at small-break (#12827)
### What?
Shows error state (red left border) on small screens.

### Why?
The current error state disappears at small-break screen width.

### How?
Updates small-break error state to match the desktop error state for the
Lexical field.

##### Reported by client.
2025-07-02 12:16:50 -07:00
Jacob Fletcher
b40c581a27 fix(ui): autosave infinite loop within document drawer (#13007)
Required for #13005.

Opening an autosave-enabled document within a drawer triggers an
infinite loop when the root document is also autosave-enabled.

This was for two reasons:

1. Autosave would run and change the `updatedAt` timestamp. This would
trigger another run of autosave, and so on. The timestamp is now removed
before comparison to ensure that sequential autosave runs are skipped.

2. The `dequal()` call was not being given the `.current` property off
the ref object. This meant that is was never evaluate to `true` and
therefore never skip unnecessary autosaves to begin with.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210697235723932
2025-07-02 15:11:38 -04:00
Patrik
335af1b8c9 fix(plugin-import-export): preview table to include all selected columns regardless of populated data (#12985)
### What?

Ensure the export preview table includes all field keys as columns, even
if those fields are not populated in any of the returned documents.

### Why?

Previously, if none of the documents in the preview result had a value
for a given field, that column would be missing entirely from the
preview table.

### How?

- Introduced a `getFlattenedFieldKeys` utility that recursively extracts
all missing flattened field accessors from the collection’s config that
are undefined

- Updates the preview UI logic to build columns from all flattened keys,
not just the first document
2025-07-02 09:28:21 -07:00
Alessio Gravili
583a733334 feat(drizzle): support half-precision, binary, and sparse vectors column types (#12491)
Adds support for `halfvec` and `sparsevec` and `bit` (binary vector)
column types. This is required for supporting indexing of embeddings >
2000 dimensions on postgres using the pg-vector extension.
2025-07-02 19:24:53 +03:00
Jessica Rynkar
6e5ddc8873 fix(examples): only allow super admins to create users with super admin role (#13015)
### What?

This PR updates the `create` access control on the `users` collection in
the `multi-tenant` example to prevent unauthorized creation of
`super-admin` users.

### Why?

Previously, any authenticated user could create a new user and assign
them the `super-admin` role — even if they didn’t have that role
themselves. This bypassed role-based restrictions and introduced a
security vulnerability, allowing users to escalate their own privileges
by working around role restrictions during user creation.

### How?

The `create` access function now checks whether the current user has the
`super-admin` role before allowing the creation of another
`super-admin`. If not, the request is denied.


**Fixes:** `CMS2-Q225-01`
2025-07-02 15:42:55 +01:00
Jarrod Flesch
9ba740e472 fix(ui): field bulk upload showing stale data (#13006) 2025-07-02 10:11:51 -04:00
Jessica Rynkar
50029532aa fix(examples): checks requested tenant matches user tenant permissions (#13012)
### What

This PR updates the `create` access control functions in the
`multi-tenant` example to ensure that any `tenant` specified in a create
request matches a tenant the user has admin access to.

### Why

Previously, while the admin panel UI restricted the tenant selection, it
was still possible to bypass this by making a request directly to the
API with a different `tenant`. This allowed users to create documents
under tenants they shouldn't have access to.

### How

The `access` functions on the `users` and `pages` collections now
explicitly check whether the tenant(s) in the request are included in
the user's tenant permissions. If not, access is denied by returning
`false`.

**Fixes: CMS2-Q225-03**
2025-07-02 14:30:47 +01:00
Jacob Fletcher
c80b6e92c4 fix(ui): prevent document drawer from remounting on save (#13005)
Supersedes #12992. Partially closes #12975.

Right now autosave-enabled documents opened within a drawer will
unnecessarily remount on every autosave interval, causing loss of input
focus, etc. This makes it nearly impossible to edit these documents,
especially if the interval is very short.

But the same is true for non-autosave documents when "manually" saving,
e.g. pressing the "save draft" or "publish changes" buttons. This has
gone largely unnoticed, however, as the user has already lost focus of
the form to interact with these controls, and they somewhat expect this
behavior or at least accept it.

Now, the form remains mounted across autosave events and the user's
cursor never loses focus. Much better.

Before:


https://github.com/user-attachments/assets/a159cdc0-21e8-45f6-a14d-6256e53bc3df

After:


https://github.com/user-attachments/assets/cd697439-1cd3-4033-8330-a5642f7810e8

Related: #12842

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210689077645986
2025-07-02 09:07:08 -04:00
Jarrod Flesch
a9580e05ac fix: disable graphql introspection queries when disableIntrospectionInProduction is true (#12982) 2025-07-02 08:33:20 -04:00
Jarrod Flesch
57d00ad2e9 test: reduce queue test amount (#13008) 2025-07-01 15:55:16 -04:00
Jarrod Flesch
a9ad7c771e fix(ui): bulk upload redirecting to relationship documents when added (#13001)
Fixes https://github.com/payloadcms/payload/issues/12786
2025-07-01 15:23:11 -04:00
Patrik
7a40a9fc06 fix(ui): skip disabled fields when adding OR filter conditions in list view (#13004)
### What?

Fixes a bug where adding an additional OR filter condition in the list
view selects a field with `admin.disableListFilter: true`, causing all
filter fields to appear disabled.

### Why?

When the first field in a collection has `disableListFilter` set to
`true`, adding a second OR condition defaults to using that field. This
leads to a broken filter UI where no valid fields are selectable.

### How?

Replaces the hardcoded usage of `reducedFields[0]` with a call to
`reducedFields.find(...) `that skips fields with `disableListFilter:
true`, consistent with the logic already used when adding the first
filter condition.

Fixes #12993
2025-07-01 11:35:48 -07:00
Patrik
b1ae749311 fix(ui): render preview sizes button when adjustments are disabled but image sizes are defined (#12999)
### What?

The "Preview Sizes" button in the file upload UI was not showing up if:
- `crop` and `focalPoint` were both `false`
- No `customUploadActions` were provided
- But image sizes were configured

### Why?

This happened because `UploadActions` wasn’t rendered at all unless
adjustments or custom actions were present.

### How?

Update the conditional in `StaticFileDetails` to also render
`UploadActions` when:
- `hasImageSizes` is `true` and the document has a `filename`

Fixes #12832
2025-07-01 07:44:48 -07:00
Jacob Fletcher
3f30a2e300 fix(ui): block rows unexpectedly collapse and array rows not collapsed on init (#12987) 2025-06-30 21:12:26 -04:00
Jarrod Flesch
c07187d804 test: fix multi-tenant flakes (#12983) 2025-06-30 17:18:41 -04:00
Sasha
0e8ac0bad5 fix(db-postgres): joins with hasMany: true relationships nested to an array (#12980)
Fixes https://github.com/payloadcms/payload/issues/12679
2025-06-30 21:25:29 +03:00
Alessio Gravili
463c9754c7 templates: fix pnpm 10 ignored build scripts warning (#12974)
When using pnpm 10 to install any of our templates, the following
warning is thrown:

![Screenshot 2025-06-29 at 13 23
28@2x](https://github.com/user-attachments/assets/450630f1-0455-48a0-96e9-516110b6146c)

> Warning: Ignored build scripts: esbuild, unrs-resolver. Run "pnpm
approve-builds" to pick which dependencies should be allowed to run
scripts.

This PR fixes this by adding those packages to `onlyBuiltDependencies`
2025-06-29 15:17:34 -07:00
Alessio Gravili
4458f74cef ci: template errors not being caught due. fix: error due to updated generated-types User type (#12973)
This PR consists of two separate changes. One change cannot pass CI
without the other, so both are included in this single PR.


## CI - ensure types are generated

Our website template is currently failing to build due to a type error.
This error was introduced by a change in our generated types.

Our CI did not catch this issue because it wasn't generating types /
import map before attempting to build the templates. This PR updates the
CI to generate types first.

It also updates some CI step names for improved clarity.

## Fix: type error

![Screenshot 2025-06-29 at 12 53
49@2x](https://github.com/user-attachments/assets/962f1513-bc6c-4e12-9b74-9b891c49900b)


This fixes the type error by ensuring we consistently use the _same_
generated `TypedUser` object within payload, instead of `BaseUser`.
Previously, we sometimes used the generated-types user and sometimes the
base user, which was causing type conflicts depending on what the
generated user type was.

It also deprecates the `User` type (which was essentially just
`BaseUser`), as consumers should use `TypedUser` instead. `TypedUser`
will automatically fall back to `BaseUser` if no generated types exists,
but will accept passing it a generated-types User.

Without this change, additional properties added to the user via
generated-types may cause the user object to not be accepted by
functions that only accept a `User` instead of a `TypedUser`, which is
what failed here.

## Templates: re-generate templates to update generated types

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210668927737258
2025-06-29 14:27:50 -07:00
Jacob Fletcher
cfc7adcbc5 fix: strict custom view paths (#12968) 2025-06-29 14:20:54 -04:00
Jarrod Flesch
16f5538e12 fix(plugin-multi-tenant): unnecessary modal appearing (#12854)
Fixes #12826 

Leave without saving was being triggered when no changes were made to
the tenant. This should only happen if the value in form state differs
from that of the selected tenant, i.e. after changing tenants.

Adds tenant selector syncing so the selector updates when a tenant is
added or the name is edited.

Also adds E2E for most multi-tenant admin functionality. 

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210562742356842
2025-06-27 16:30:13 -04:00
Said Akhrarov
9f6030641a fix: appropriately throw unverified email error (#12933)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
### What?
This PR addresses an issue where the order of operations/conditions for
throwing an unverified email error were incorrect.

### Why?
To properly throw an unverified email error under the correct
conditions.

### How?
Pushing this error to be thrown later in the operation.
2025-06-27 19:26:37 +00:00
Jacob Fletcher
f2213e5c5c feat: mount live preview to document root (#12860)
Mounts live preview to `../:id` instead `../:id/preview`.

This is a huge win for both UX and a maintainability standpoint.

Here are just a few of those wins:

1. If you edit a document, _then_ decide you want to preview those
changes, you are currently presented with the `LeaveWithoutSaving` modal
and are forced to either save your edits or clear them. This is because
you are being navigated to an entirely new page with it's own form
context. Instead, you should be able to freely navigate back and forth
between the two.
2. If you are an editor who most often uses Live Preview, or you are
editing a collection that typically requires it, you likely want it to
automatically enter live preview mode when you open a document.
Currently, the user has to navigate to the document _first_, then use
the live preview tab. Instead, you should be able to set a preference
and avoid this extra step.
3. Since the inception of Live Preview, we've been maintaining largely
the same code across the default edit view and the live preview view,
which often became out of sync and inconsistent—but they're essentially
doing the same thing. While we could abstract a lot of this out, it is
no longer necessary if the two views are combined into one.

This change does also include some small modifications to UI. The "Live
Preview" tab no longer exists, and instead has been replaced with a
button placed next to the document controls (subject to change).

Before:


https://github.com/user-attachments/assets/48518b02-87ba-4750-ba7b-b21b5c75240a

After:


https://github.com/user-attachments/assets/a8ec8657-a6d6-4ee1-b9a7-3c1173bcfa96
2025-06-27 11:58:00 -04:00
Jessica Rynkar
6f6d305f9d fix(ui): prevent error if rows is undefined in mergeServerFormState (#12962)
### What? 
Adds optional chaining when accessing `rows` in `mergeServerFormState`
to prevent error crashing the UI.

### Why? 
When an array field is populated in a `beforeChange` hook and was
previously empty, it crashes `mergeServerFormState.ts` on this line
because no `rows` exist:

```ts 
const indexInCurrentState = currentState[path].rows.findIndex
``` 

The line after this checks `if (indexInCurrentState > -1)` so returning
undefined here will not affect the subsequent code.

### How? 
Added optional chaining to the access of `rows`, which prevents the
error being thrown.

Fixes #12944
2025-06-27 15:57:48 +00:00
Paul
c902f14cb3 fix(db-mongodb): add ability to disable fallback sort and no longer adds a fallback for unique fields (#12961)
You can now disable fallback sort in the mongodb adapter by passing
`disableFallbackSort: true` in the options.

We also no longer add fallback sort to sorts on unique fields by default
now.

This came out of a discussion in this issue
https://github.com/payloadcms/payload/issues/12690
and the linked PR https://github.com/payloadcms/payload/pull/12888

Closes https://github.com/payloadcms/payload/issues/12690
2025-06-27 13:45:30 +00:00
Elliot DeNolf
c66e5ca823 chore(release): v3.44.0 [skip ci] 2025-06-27 09:23:04 -04:00
James Mikrut
26d709dda6 feat: auth sessions (#12483)
Adds full session functionality into Payload's existing local
authentication strategy.

It's enabled by default, because this is a more secure pattern that we
should enforce. However, we have provided an opt-out pattern for those
that want to stick to stateless JWT authentication by passing
`collectionConfig.auth.useSessions: false`.

Todo:

- [x] @jessrynkar to update the Next.js server functions for refresh and
logout to support these new features
- [x] @jessrynkar resolve build errors

---------

Co-authored-by: Elliot DeNolf <denolfe@gmail.com>
Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
Co-authored-by: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com>
Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
2025-06-27 09:13:52 -04:00
Jacob Fletcher
c8b72141e4 feat: collection-level preferences (#12909)
Needed for #12860.

The new live preview pattern requires collection-level preferences, a
pattern that does not yet exist.

Instead of creating a new record for these types of preferences, we can
simply reuse `<collectionSlug>-list` under a more general key:
`collection-<slug>`. This way other relevant properties can be attached
in the future that might not specifically apply to the list view.

This will also match the conventions already estalished by
document-level preferences in `collection-<slug>-<id>` and
`global-<slug>`.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210628212784050
2025-06-27 09:08:47 -04:00
Elliot DeNolf
1db06195c2 ci: bring back CODEOWNERS file for reviews, approval not required [skip ci] 2025-06-27 09:00:52 -04:00
Roman
6a935d4d4d examples: fix broken navigation to post in localization example (#12810)
This pull request updates the `Card` component in the localization
example to support localized URLs. The most significant changes include
importing a new hook for locale management and modifying the URL
generation logic to include the locale.

Localization updates:

*
[`examples/localization/src/components/Card/index.tsx`](diffhunk://#diff-619212c47638e7ff51284c62740ba188c87f008d481442b7f4951e2c150a2415R5):
Imported `useLocale` from `next-intl` to manage locale-based
functionality.
*
[`examples/localization/src/components/Card/index.tsx`](diffhunk://#diff-619212c47638e7ff51284c62740ba188c87f008d481442b7f4951e2c150a2415R20):
Added a `locale` constant using the `useLocale` hook to retrieve the
current locale.
*
[`examples/localization/src/components/Card/index.tsx`](diffhunk://#diff-619212c47638e7ff51284c62740ba188c87f008d481442b7f4951e2c150a2415L28-R30):
Updated the `href` generation logic to include the locale in the URL
structure, ensuring localized navigation.
2025-06-27 11:11:16 +00:00
Jesper We
c3c1614fa6 fix(ui): usePreventLeave should not show alert for exceptions (#12722)
When using 3rd party custom components in an edit form there exists a
possibility that a non-navigational click event will propagate through
to payload.

In this case the `findClosestAnchor` function in `usePreventLeave` may
find an anchor without href, resulting in the `newUrlObj = new
URL(newUrl)` in `isAnchorOfCurrentUrl` throwing the exception:

> TypeError: URL constructor:  is not a valid URL.

As a result a native alert is shown to the user, with no real
explanation as to what is going on. This is not a good experience.

I suggest moving it to a console log which is less "in your face" for
users who do not know what to do about it anyway.

I discovered this while using a data grid component with a context menu.
Clicking on menu items (which are `<a>` tags without href in this
component) triggers the error.

(Another on-liner fix would ofc be to not attempt to create an URL
object if there is no href `if (anchor?.href) {`, but I opted for this
version since using `alert()` in production code is not a preferred
practice anyway)

<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
2025-06-27 07:00:04 -04:00
ThijsAtFreave
e7695502e3 fix: richTextField supports beforeInput/afterInput, but these were missing from types.ts (#12889)
Add `afterInput` and `beforeInput` to `admin.components` of
RichTextField type. These props are supported but missing from types.
2025-06-27 06:50:04 -04:00
Sam Wheeler
0e9865c564 fix(ui): vertically align table headers to the middle (#12699)
This fixes a small ui bug where the items in the table header were not
vertically aligned when they don't contain the SortColumn component. The
SortColumn component handles vertical alignment with a nested flexbox.
The PR adds vertical-align: middle directly to the th element so that
the text in the header is vertically aligned even when there isn't a
nested flexbox

Before:
<img width="719" alt="Screenshot 2025-06-05 at 10 24 19 AM"
src="https://github.com/user-attachments/assets/3962517e-3b22-452a-af04-8397549c4ed9"
/>

After:
<img width="719" alt="Screenshot 2025-06-05 at 10 30 39 AM"
src="https://github.com/user-attachments/assets/0c5a0847-8ee2-4439-981e-f3538908e920"
/>
2025-06-27 06:43:41 -04:00
Chandler Gonzales
e5e0ec86c5 docs: remove group from list of default field validations (#12921)
### What?

Removes group from the list of default field validations in the docs

### Why?

It doesn't exist in the code:
886c07e918/packages/payload/src/fields/validations.ts
2025-06-27 06:34:22 -04:00
Jessica Rynkar
c76d83985d fix(plugin-multi-tenant): updates tenant selector upon tenant creation (#12936)
### What?
Updates the tenant selector displayed in the sidebar when a new tenant
is created.

### Why?
Currently when using the multi-tenant plugin and creating a new tenant
doc, the tenant selector dropdown does not display the new tenant as an
option until the page gets refreshed.

### How?
Extends the `WatchTenantCollection` helper to check if the tenant `id`
from the current doc exists, if the tenant is new it manually calls
`updateTenants`. The `updateTenants` function previously only adjusted
the title on existing tenants, this has been updated to add a new tenant
as an option when it doesn't exist.

#### Reported by client
2025-06-27 06:26:05 -04:00
Said Akhrarov
a1822d21d0 fix(ui): properly render create new button in polymorphic joins (#12930)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
### What?
This PR fixes an issue where the bottom "Create new ..." button would
cause a runtime error due to not accounting for a polymorphic join
setup.

### Why?
To prevent a runtime error and allow users the ability to add new
documents to the join as expected even in a polymorphic setup.

### How?
Creation of a new `AddNewButton` which handles all of the add new button
instances in the `RelationshipTable` component.

Addresses
https://github.com/payloadcms/payload/issues/12913#issuecomment-3001475438

Before:


[join-polymorphic-runtime-error--Payload.webm](https://github.com/user-attachments/assets/fad3a1ba-c51c-4731-84cc-c27adbaac1d9)


After:

[polymorphic-after-Editing---Multiple-Collections-Parent---Payload
(1).webm](https://github.com/user-attachments/assets/e3baf902-1b2b-4f19-8b6d-838edd6fef80)
2025-06-27 05:47:36 -04:00
Dani Calero 🚀
4b9566f8b8 fix(ui): render DateTime label as <label> instead of <span> (#12949)
## What / Why
Date & Time fields were rendering their field label as a `<span>` while
every other field type uses a proper `<label>` with a matching
`htmlFor`.

Because the element was a span it broke styles and made 'field-label'
have different styles from the rest of 'field-label's.

**Root cause:** DateTimeField failed to pass its `path` (or an explicit
`htmlFor`) to `FieldLabel`. When `FieldLabel` receives no `htmlFor`, it
intentionally downgrades to a `<span>`.

## Screenshots

### Before

![image](https://github.com/user-attachments/assets/edecfce7-0326-4f3e-af76-d7b37158343a)
*DateTime label rendered as `<span>`, causing style inconsistencies*

### After  

![image](https://github.com/user-attachments/assets/d9fb06c2-1ca0-4f8d-803d-15c6c6355d1e)
*DateTime label now rendered as proper `<label>` element*

## Changes introduced
- `packages/ui/src/fields/DateTime/index.tsx`
  - Added `path={path}` prop to `FieldLabel` component

## Behavior after the fix
- Date-time labels are now real `<label>` elements with `for="field-…"`
- Visual alignment now matches every other field type  

## How to test manually
1. Run `pnpm dev fields`
2. Inspect the DateTime field markup – label is now `<label>` 
3. Observe that vertical spacing matches other types of fields
2025-06-27 05:34:28 -04:00
Sasha
54afaf9529 fix(db-mongodb): strip deleted from the config blocks from the result (#12869)
If you (using the MongoDB adapter) delete a block from the payload
config, but still have some data with that block in the DB, you'd
receive in the admin panel an error like:
```
Block with type "cta" was found in block data, but no block with that type is defined in the config for field with schema path pages.blocks
```

Now, we remove those "unknown" blocks at the DB adapter level.

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-06-27 05:30:48 -04:00
Patrik
3830d710a4 feat(plugin-import-export): preview displays CSV and JSON data accurately (#12948)
### What

This PR updates the import-export plugin's `<Preview />` component to
render table columns and rows using the same logic as the CSV export.

Key changes:
- Adds a new `/api/preview-data` custom REST endpoint that:
  - Accepts filters (`fields`, `where`, `sort`, `draft`, `limit`)
- Uses `getCustomFieldFunctions` and `flattenObject` to transform
documents
  - Returns deeply flattened rows identical to the CSV export
- Refactors the <Preview /> component to:
- POST preview config to the new endpoint instead of querying the
collection directly
- Match column ordering and flattening logic with the `createExport`
function
- Ensures consistency across CSV downloads and in-admin previews
-Adds JSON preview

This ensures preview results now exactly match exported CSV content,
including support for custom field transformers and polymorphic fields.

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-06-27 05:10:28 -04:00
Dave Ryan
2da6d924de fix: validate "null" value for point field as true when its not required (#12908)
### What?

This PR solves an issue with validation of the `point` field in Payload
CMS. If the value is `null` and the field is not required, the
validation will return `true` before trying to examine the contents of
the field

### Why?

If the point field is given a value, and saved, it is then impossible to
successfully "unset" the point field, either through the CMS UI or
through a hook like `beforeChange`. Trying to do so will throw this
error:

```
[17:09:41] ERROR: Cannot read properties of null (reading '0')
    err: {
      "type": "TypeError",
      "message": "Cannot read properties of null (reading '0')",
      "stack":
          TypeError: Cannot read properties of null (reading '0')
              at point (webpack-internal:///(rsc)/./node_modules/.pnpm/payload@3.43.0_graphql@16.10.0_typescript@5.7.3/node_modules/payload/dist/fields/validations.js:622:40)
```

because a value of `null` will not be changed to the default value of
`['','']`, which in any case does not pass MongoDB validation either.

```
[17:22:49] ERROR: Cast to [Number] failed for value "[ NaN, NaN ]" (type string) at path "location.coordinates.0" because of "CastError"
    err: {
      "type": "CastError",
      "message": "Cast to [Number] failed for value \"[ NaN, NaN ]\" (type string) at path \"location.coordinates.0\" because of \"CastError\"",
      "stack":
          CastError: Cast to [Number] failed for value "[ NaN, NaN ]" (type string) at path "location.coordinates.0" because of "CastError"
              at SchemaArray.cast (webpack-internal:///(rsc)/./node_modules/.pnpm/mongoose@8.15.1_@aws-sdk+credential-providers@3.778.0/node_modules/mongoose/lib/schema/array.js:414:15)
```


### How?

This adds a check to the top of the `point` validation function and
returns early before trying to examine the contents of the point field

---------

Co-authored-by: Dave Ryan <dmr@Daves-MacBook-Pro.local>
2025-06-27 07:49:47 +00:00
Jarrod Flesch
86e48ae70b test: bulk edit flaky selectors (#12950)
https://github.com/payloadcms/payload/pull/12861 introduced some flaky
test selectors. Specifically bulk editing values and then looking for
the previous values in the table rows.

This PR fixes the flakes and fixes eslint errors in `findTableRow` and
`findTableCell` helper funcitons.
2025-06-26 22:40:19 -04:00
Kendell
7ebac630f7 test: adds test for skipSafeFetch allowList (#12954)
Adds missing test in PR: #12927
2025-06-26 17:14:49 -04:00
Ondřej Závodný
7472798808 fix(live-preview): client-side live preview cannot populate more than 10 relationships at once (#12929)
### What?

Set the `limit` query param on API requests called within the
`useLivePreview` hook.

### Why?

We are heavily relying on the block system in our pages and we reuse the
media collection in a lot of the block types. When the page has more
than 10 images, the API request doesn't fetch all of them for live
preview due to the default 10 item `limit`. This PR allows the preview
page to override this `limit` so that all the items get correctly
fetched.

### Our current workaround

Set the `depth` param of `useLivePreview` hook like this:

```
useLivePreview({
  // ...
  depth: '1000&limit=1000',
})
```

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

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-06-26 16:36:49 -04:00
Said Akhrarov
605c993bb7 fix(drizzle): skip column if undefined in findMany (#12902)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
### What?
This PR fixes an issue where sorting on a traditional virtual field with
`virtual: true` while using a drizzle-based db adapter would cause a
runtime error.

### Why?
To skip attempting to sort virtual fields which are not linked to a
relationship/upload and prevent a runtime error from surfacing.

### How?
Skipping the deletion of the property from the `selectFields` object if
the column is false-y.

Fixes #12886

Before:


[sort-virtualfield-drizzle-error.mp4](https://private-user-images.githubusercontent.com/78685728/457602747-b8661e47-a1a8-4453-b2ec-b7e7199b9846.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTA2OTU0NzksIm5iZiI6MTc1MDY5NTE3OSwicGF0aCI6Ii83ODY4NTcyOC80NTc2MDI3NDctYjg2NjFlNDctYTFhOC00NDUzLWIyZWMtYjdlNzE5OWI5ODQ2Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA2MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNjIzVDE2MTI1OVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTE3NmMzOWI5YjNiNzEwYzk3ZWUyNDllYTBjMzZkNzkzMjhjNzc5YzJhNDlkOTBiNDk5MDFhMTdmNDA4NjJhZWQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.N1GJsiI_gZ8M54VHCAmiPEhcJGqRw3Ucy-VeM5R7UFE)

After: 


[virtualfields-sort-Posts---Payload.webm](https://github.com/user-attachments/assets/f5a15d98-4a40-4817-bc6a-415f3ec27484)

<details>

<summary>Collection config used above</summary>

```ts
export const PostsCollection: CollectionConfig = {
  slug: postsSlug,
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'exampleField'],
  },
  fields: [
    {
      name: 'title',
      type: 'text',
    },
    {
      name: 'exampleField',
      type: 'text',
      virtual: true,
      admin: {
        readOnly: true,
      },
      hooks: {
        afterRead: [({ data }) => data?.title],
      },
    },
    {
      type: 'relationship',
      name: 'category',
      relationTo: 'categories',
    },
    {
      name: 'categoryTitle',
      type: 'text',
      virtual: 'category.title',
    },
  ],
}
```

</details>

---------

Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
2025-06-26 19:52:29 +00:00
Kendell
a7ad573a0e fix: get external resource blocked (#12927)
## Fix
- Use `[Config].upload.skipSafeFetch` to allow specific external urls
- Use `[Config].upload.pasteURL.allowList` to allow specific external
urls

Documentation: [Uploading files from remote
urls](https://payloadcms.com/docs/upload/overview#uploading-files-from-remote-urls)

Fixes: https://github.com/payloadcms/payload/issues/12876
Mentioned: https://github.com/payloadcms/payload/issues/7037,
https://github.com/payloadcms/payload/issues/12934
Source PR: https://github.com/payloadcms/payload/pull/12622
Issue Trace:
1. [`allowList`
Added](8b7f2ddbf4 (diff-92acf7b8d30e447a791e37820136bcbf23c42f0358daca0fdea4e7b77f7d4bc9)
)

2. [`allowList`
Removed](648c168f86 (diff-92acf7b8d30e447a791e37820136bcbf23c42f0358daca0fdea4e7b77f7d4bc9))
2025-06-26 15:24:39 -04:00
Jarrod Flesch
d62d9b4b8e fix(ui): bulk upload losing state when adding additional files (#12946)
Fixes an issue where adding additional upload files would clear the
state of the originally uploaded files.
2025-06-26 15:23:38 -04:00
Jacob Fletcher
67fa5a0b3b fix(live-preview): foreign postMessage events reset client-side state (#12925)
Needed for #12860.

If the admin panel broadcasts foreign postMessage events, i.e. those
without the `payload-live-preview` signature, client-side live preview
subscriptions will reset back to initial state.

This is because we dispatch two postMessage events in the admin panel,
one for client-side live preview to catch (`payload-live-preview`), and
the other for server-side live preview (`payload-document-event`). This
was not previously noticeable because both events would only get called
simultaneously on initial render, where initial state is already the
expected result.

Now that Live Preview can be freely toggled on and off, both events are
frequently dispatched and very obviously disregard the current working
state.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210628466702818
2025-06-26 14:05:21 -04:00
Jacob Fletcher
bcb10b52b3 fix: restore missing properties to live preview client config (#12904)
Needed for #12860.

The client config unnecessarily omits the `livePreview.collections` and
`livePreview.globals` properties. This is because the root live preview
config extends the type with these two additional properties without
sharing it elsewhere. To led to the client sanitization function
overlooking these additional properties, as there was no type indication
that they exist.

The `collections` and `globals` properties are now appended to the
client config as expected, and the root live preview is standardized
behind the `RootLivePreviewConfig` type to ensure no properties are
lost.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210628466702823
2025-06-26 14:05:10 -04:00
Paul
87c7952558 feat(templates): added int and e2e tests to blank and website templates (#12866)
This PR adds int tests with vitest and e2e tests with playwright
directly into our templates.

The following are also updated:
- bumps core turbo to 2.5.4 in monorepo
- blank and website templates moved up to be part of the monorepo
workspace
- this means we now have thes templates filtered out in pnpm commands in
package.json
- they will now by default use workspace packages which we can use for
manual testing and int and e2e tests
  - note that turbo doesnt work with these for dev in monorepo context
- CPA script will fetch latest version and then replace `workspace:*` or
the pinned version in the package.json before installation
- blank template no longer uses _template as a base, this is to simplify
management for workspace
- updated the generate template variations script
2025-06-26 13:55:28 -04:00
Jacob Fletcher
141133a27f fix(next): live preview popup triggers leave without saving modal (#12947)
Partially closes #12121.

When you edit a document in Live Preview using the default iframe
window, then attempt to open the window as a popup, the
`LeaveWithoutSaving` modal will appear.

This is because the `usePreventLeave` hook watches for anchor tags that
might cause a page navigation, and rightfully warns the user before they
navigate away and lose their changes. The reason the popup button
triggers this hook is because it uses an anchor tag with an href for
accessibility, which fires events that are caught and processed by the
hook.

The fix is to add the `target="_blank"` attribute here so that the hook
understands that these events do not navigate the user away from the
page and can be ignored.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210643905956946
2025-06-26 16:59:20 +00:00
Ruby Jasmin
379fc127cc fix(ui): unreachable custom views when admin route set to '/' (#12812)
### What?
Fixes #12811

### Why?
Custom Views become unreachable when admin route is set to "/" because
the forward slash of the current route gets removed before routing to
custom view

### How?

Fixes #

-->

Fixes #12811

Custom Views become unreachable when admin route is set to "/" because
the forward slash of the current route gets removed before routing to
custom view

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

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-06-26 09:34:08 -04:00
Patrik
5cf92878a4 fix(plugin-import-export): duplicated rows and headers in CSV export when streaming paginated results (#12941)
This PR fixes an issue in the export logic where CSV downloads would
include duplicate rows and repeated column headers across paginated
batches.

Key changes:
- Ensured `page` is incremented correctly after each `payload.find` call
- Tracked and wrote CSV column headers only once for the first page
- Prevented row duplication by removing unused `result` initialization
and using isolated `page` tracking
- Streamlined both download and non-download logic for consistent batch
processing

This resolves incorrect row counts and header duplication in large CSV
exports.
2025-06-26 06:09:17 -07:00
Jarrod Flesch
8900a38678 fix: uses valid fractional index for test (#12942) 2025-06-26 06:40:18 -04:00
Paul
5368440115 chore: fix jest global teardown incorrectly always returning process exit status 0 (#12907)
We were running scripts as they were without encompassing our logic in a
function for jest's teardown and we were subsequently running
`process.exit(0)` which meant that tests didn't correctly return an
error status code when they failed in CI.

The following tests have been skipped as well:
```
  ● postgres vector custom column › should add a vector column and query it
  ● Sort › Local API › Orderable › should not break with existing base 62 digits
  ● Sort › Local API › Orderable join › should set order by default
  ● Sort › Local API › Orderable join › should allow setting the order with the local API
  ● Sort › Local API › Orderable join › should sort join docs in the correct
```

---------

Co-authored-by: Elliot DeNolf <denolfe@gmail.com>
Co-authored-by: Alessio Gravili <alessio@gravili.de>
2025-06-25 17:43:57 -07:00
Said Akhrarov
9f17db8a7b fix(ui): toggle list selections off on successful bulk action (#12861)
### What?
This PR threads an onSuccess callback to bulk actions which get called
after a successful action. In this case, the callback toggles the list
selections off after a successful edit many, publish many, or unpublish
many.

### Why?
To ensure list selections are toggled off after a successful action.

### How?
By threading a new onSuccess callback through the actions' props.

Fixes #12855

Before


[12855-before.mp4](https://private-user-images.githubusercontent.com/65888/456602476-b327f0ba-c140-46be-8c71-7f6bfa74fd67.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTAyODQxMDEsIm5iZiI6MTc1MDI4MzgwMSwicGF0aCI6Ii82NTg4OC80NTY2MDI0NzYtYjMyN2YwYmEtYzE0MC00NmJlLThjNzEtN2Y2YmZhNzRmZDY3Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA2MTglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNjE4VDIxNTY0MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTA0YTE4OTE5MjliZWQxNDM1OTU0ODlhMmY5ZjliNjhlODAyODU5ZmU3ODkzMjI1ODhiOTQyNmY0YzMyMGM0ZmQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.hzTLtuzltcpQUAIHYz7JoZ5x7JT4dPP9f-3c-GDf0Zc)

After


[Draft-Posts---Payload.webm](https://github.com/user-attachments/assets/474fbd9f-c7b3-46f4-ae31-5246cb22b86d)
2025-06-25 17:06:44 -04:00
Elliot DeNolf
b1a57fa350 chore: set trimTrailingWhitespace and insertFinalNewline in vscode settings (#12939)
Add the following to our vscode settings

```json
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
  ```
2025-06-25 11:08:10 -07:00
Sasha
c1f62972da fix(db-postgres): joins with custom schema (#12937)
Fixes normal and polymorphic joins when using a custom schema in
Postgres
2025-06-25 13:51:39 -04:00
Jessica Rynkar
c094b0e520 fix(ui): align caret on error tooltip for checkbox field (#12917)
### What?
Aligns the caret on the error message tooltip to the left when using a
checkbox field.

### Why?
All field error message tooltips have a right-aligned caret - when using
a checkbox field, this results in the caret pointing to open space (see
screenshots).

### How?
Left aligns the tooltip caret just for the checkbox field.

**Before:**
![Screenshot 2025-06-24 at 2 45
38 PM](https://github.com/user-attachments/assets/923f6a06-1f24-468d-88d8-12e3f0f0d27f)

**After:**
![Screenshot 2025-06-24 at 2 46
47 PM](https://github.com/user-attachments/assets/a2ebbe6a-2095-4295-9e94-320a1b943a6d)


#### Reported by client
2025-06-25 15:48:49 +00:00
Patrik
1cdec861cd test: guard against null values in custom toCSV functions (#12938)
### What?

Fixes a crash when exporting documents to CSV if a custom `toCSV`
function tries to access properties on a `null` value.

### Why?

In some cases (especially with Postgres), fields like relationships may
be explicitly `null` if unset. Custom `toCSV` functions that assume the
value is always defined would throw a `TypeError` when attempting to
access nested properties like `value.id`.

### How?

Added a null check in the custom `toCSV` implementation for
`customRelationship`, ensuring the field is an object before accessing
its properties.

This prevents the export from failing and makes custom field transforms
more resilient to missing or optional values.
2025-06-25 11:45:09 -04:00
Patrik
6d768748a0 fix(plugin-import-export): csv export for polymorphic relationship fields (#12926)
### What?

Fixes CSV export support for polymorphic relationship and upload fields.

### Why?

Polymorphic fields in Payload use a `{ relationTo, value }` structure.
The previous implementation incorrectly accessed `.id` directly on the
top-level object, which caused issues depending on query depth or data
shape. This led to missing or invalid values in exported CSVs.

### How?

- Updated getCustomFieldFunctions to safely access relationTo and
value.id from polymorphic fields

- Ensured `hasMany` polymorphic fields export each related ID and
relationTo as separate CSV columns
2025-06-25 11:44:31 -04:00
Jessica Rynkar
1845669e68 fix(ui): updates auth fields UI to reflect access control (#12745)
### What?
Reflects any access control restrictions applied to Auth fields in the
UI. I.e. if `email` has `update: () => false` the field should be
displayed as read-only.

### Why?
Currently any access control that is applied to auth fields is
functional but is not matched within the UI.

For example:
- `password` that does not have read access will not return data, but
the field will still be shown when it should be hidden
- `email` that does not have update access, updating the field and
saving the doc will **not** update the data, but it should be displayed
as read-only so nothing can be filled out and the updating restriction
is made clear

### How?
Passes field permissions through to the Auth fields UI and adds docs
with instructions on how to override auth field access.

#### Testing
Use `access-control` test suite and `auth` collection. Tests added to
`access-control` e2e.

Fixes #11569
2025-06-25 14:55:07 +01:00
Jarrod Flesch
0d50799b79 fix(ui): folder server function must reference exports dir (#12898) 2025-06-25 09:52:39 -04:00
Jessica Rynkar
37c945b95b fix(ui): custom row labels on arrays should not be removed on field duplication (#12895)
### What?
This fix prevents custom row labels being removed when duplicating array
items.

### Why?
Currently, when you have an array with custom row labels, if you create
a new array item by duplicating an existing item, the new item will have
no custom row label until you refresh the page.

### How?
During the `duplicate` process, we remove any react components from the
field state. This change intentionally re-adds the `RowLabel` if one
exists.

#### Reported by client
2025-06-25 09:44:00 -04:00
Jacob Fletcher
20bbbcfca2 fix(ui): date format of useAsTitle lost after changing value (#12928)
When a collection's `admin.useAsTitle` property points to a date field,
the date format is lost after making a change to the field's value.

Before:


https://github.com/user-attachments/assets/10e61517-3245-4645-be4c-33017bfc860c

After:


https://github.com/user-attachments/assets/d3d62d2e-364e-48a2-91c1-2ce4b0962fe5

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210632330039313
2025-06-25 09:15:55 -04:00
Sasha
cf87871fbd test: fix database/int.spec.ts with postgres custom schema (#12922)
The test was failing because in case you have a custom schema, you need
to use `payload.db.pgSchema.table` instead of `pgTable` to define a
table
2025-06-24 15:07:17 -04:00
Patrik
751691aeaf fix(plugin-import-export): omit CSV columns when toCSV returns undefined (#12923)
### What?

Ensure fields using a custom `toCSV` function that return `undefined`
are excluded from the exported CSV.

### Why?

Previously, when a `toCSV` function returned `undefined`, the field key
would still be added to the export row. This caused the column to appear
in the CSV output with an empty string value (`""`), leading to
unexpected results and failed assertions in tests expecting the field to
be truly omitted.

### How?

Updated the `flattenObject` utility to:
- Check if the value returned by a `toCSV` function is `undefined`
- Only assign the value to the export row if it is explicitly defined
- Applied this logic in all relevant paths (arrays, objects, primitives)

This change ensures that fields are only included in the CSV when a
meaningful value is returned.
2025-06-24 11:34:58 -07:00
Anatoly Kopyl
c03e9c1724 fix(ui): properly differentiate between DOM events and raw values in setValue (#12892)
Because of this check, if a JSON with a property `target` was saved it
would become malformed.

For example trying to save a JSON field:

```json
{
  "target": {
    "value": {
      "foo": "bar"
    }
  }
}
```

would result in:

```json
{
  "foo": "bar"
}
```

And trying to save:

```json
{
  "target": "foo"
}
```

would just not save anything:

```json
null
```

I went through all of the field types and did not find a single one that
would rely on this ternary. Seems like it always defaulted to `const val
= e`, except the unexpected case described previously.

Fixes #12873

Added test may be overkill, will remove if so.




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

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-06-24 14:30:52 -04:00
Sasha
b74969d720 fix(db-postgres): querying on hasMany: true select field in a relationship (#12916)
Fixes https://github.com/payloadcms/payload/issues/11635
2025-06-24 21:25:48 +03:00
Said Akhrarov
39e95195e1 fix(next): prevent errors in globals version view (#12920)
### What?
This PR fixes a runtime error that occurs when opening the "More
versions..." drawer while browsing the versions for a global. It also
fixes a minor runtime error when navigating to a global version view
where an optional chaining operator was missing as the collection
variable would be undefined as we are viewing a global.

This PR also adds an e2e test to ensure the versions drawer is
accessible and renders the appropriate number of versions for globals.

### Why?
To properly render global version views without errors.

### How?
By threading the global slug to the versions drawer and adjusting some
properties of the `renderDocument` server function call there. This PR
also adds an optional chaining operator the `versionUseAsTitle` in the
original view to prevent an error in globals.

Notes:
- This was brought to my attention in Discord by a handful of users

Before: (Missing optional chaining error)


[error1-verions-Editing---Menu---Payload.webm](https://github.com/user-attachments/assets/3dc4dbe4-ee5a-43df-8d25-05128b05e063)

Before: (Versions drawer error)


[error2-versions-Editing---Menu---Payload.webm](https://github.com/user-attachments/assets/98c3e1da-cb0b-4a36-bafd-240f641e8814)


After:


[versions-globals-Dashboard---Payload.webm](https://github.com/user-attachments/assets/c778d3f0-a8fe-4e31-92cb-62da8e6d8cb4)
2025-06-24 13:18:25 -04:00
Sasha
886c07e918 test: fix database integration tests with postgres (#12919)
Fixes failing postgres integration tests in the `database` test suite
2025-06-24 10:59:47 -04:00
Alessio Gravili
053192c488 refactor: changed default exports to named exports in payload package (#12871)
This changes all remaining default exports to named exports in the
payload package and removes all unnecessary internal-only barrel export
files. => Less lines of code, less eslint warnings

![Screenshot 2025-06-19 at 14 02
23@2x](https://github.com/user-attachments/assets/bcbe2394-07b5-49b4-86c7-30243679bb61)
2025-06-24 04:38:02 +00:00
Sasha
bc9b501e28 fix: querying virtual fields deeply with draft: true (#12868)
Fixes an issue when querying deeply new relationship virtual fields with
`draft: true`. Changes the method for `where` sanitization, before it
was done in `validateSearchParam` which didn't work with versions
properly, now there's a separate `sanitizeWhereQuery` function that does
this.
2025-06-23 22:18:49 -04:00
Alessio Gravili
bb17cc3ea8 refactor: remove unused assets, move remaining assets out of payload packages (#12874)
This PR removes the `packages/payload/src/assets` folder for the
following reasons:
- they were published to npm. Removing this decreases the install size
of payload (excluding dependencies) from 6.22MB => 5.12MB
- most assets were unused. The only used ones were moved to a different
directory that does not get published to npm

This also updates some outdated asset URLs in our examples
2025-06-23 23:23:44 +00:00
Jacob Fletcher
1b5e3fe8ba fix(next): remove error handling from next auth functions (#12897)
The `@payloadcms/next/auth` functions are unnecessarily wrapped with
`try...catch` blocks that propagate the original error as a plain
string. This makes it impossible for the end user's error handling to
differentiate between error types.

These functions also throw errors regardless, and therefore must be
wrapped with proper error handling anyway. Especially after removing the
internal logging in #12881, these blocks do not serve any purpose.

This PR also removes unused imports.
2025-06-23 16:16:37 -04:00
Elliot DeNolf
ca0d0360e0 ci: revert bump pnpm to v10 (#12840) (#12906)
The bump to pnpm v10 was causing too many mysterious timeouts in a few
places. Reverting until we can fully investigate.
2025-06-23 15:10:51 -04:00
Chandler Gonzales
fe58f03189 fix(next): remove console.error from next auth functions (#12881)
### What?

Removes the console.error() statement when there is a login error.

### Why?

IMO, Libraries should not pollute the console with log statements in all
but the most exceptional cases. This prevents users of the library from
controlling what goes to standard out. For example, if I want to use
structured logging, this log line breaks it.

It would be a little better if this console.error() only executed on
unexpected errors, but it executes even when a user puts the wrong email
/ password, so it gets printed relatively frequently.

I think you can just remove the logging and let the user of this
function catch the error and log as they see fit.

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-06-23 15:01:29 -04:00
Elliot DeNolf
c7dc1b46c2 ci: add timeout-minutes for int and e2e (#12903)
Setting `timeout-minutes` to `45` for all int and e2e tests.
2025-06-23 13:56:16 -04:00
583 changed files with 15857 additions and 3694 deletions

34
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,34 @@
# Order matters. The last matching pattern takes precedence
## Package Exports
**/exports/ @denolfe @DanRibbens
## Packages
/packages/create-payload-app/src/ @denolfe
/packages/email-*/src/ @denolfe
/packages/eslint-*/ @denolfe @AlessioGr
/packages/plugin-cloud-storage/src/ @denolfe
/packages/plugin-multi-tenant/src/ @JarrodMFlesch
/packages/richtext-*/src/ @AlessioGr
/packages/storage-*/src/ @denolfe
/packages/ui/src/ @jacobsfletch @AlessioGr @JarrodMFlesch
## Templates
/templates/_data/ @denolfe
/templates/_template/ @denolfe
## Build Files
**/jest.config.js @denolfe @AlessioGr
**/tsconfig*.json @denolfe @AlessioGr
## Root
/.github/ @denolfe
/.husky/ @denolfe
/.vscode/ @denolfe @AlessioGr
/package.json @denolfe
/tools/ @denolfe

View File

@@ -10,7 +10,7 @@ inputs:
pnpm-version:
description: Pnpm version
required: true
default: 10.12.1
default: 9.7.1
pnpm-run-install:
description: Whether to run pnpm install
required: false

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -40,7 +40,7 @@ There are a couple ways run integration tests:
- **Granularly** - you can run individual tests in vscode by installing the Jest Runner plugin and using that to run individual tests. Clicking the `debug` button will run the test in debug mode allowing you to set break points.
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/int-debug.png" />
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/int-debug.png" />
- **Manually** - you can run all int tests in the `/test/_community/int.spec.ts` file by running the following command:
@@ -57,7 +57,7 @@ The easiest way to run E2E tests is to install
Once they are installed you can open the `testing` tab in vscode sidebar and drill down to the test you want to run, i.e. `/test/_community/e2e.spec.ts`
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/e2e-debug.png" />
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/e2e-debug.png" />
#### Notes

View File

@@ -1,4 +1,4 @@
name: build
name: ci
on:
pull_request:
@@ -18,7 +18,7 @@ concurrency:
env:
NODE_VERSION: 23.11.0
PNPM_VERSION: 10.12.1
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
@@ -163,6 +163,7 @@ jobs:
needs: [changes, build]
if: ${{ needs.changes.outputs.needs_tests == 'true' }}
name: int-${{ matrix.database }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -174,6 +175,7 @@ jobs:
- supabase
- sqlite
- sqlite-uuid
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@@ -185,7 +187,8 @@ jobs:
services:
postgres:
image: ${{ (startsWith(matrix.database, 'postgres') ) && 'postgis/postgis:16-3.4' || '' }}
# Custom postgres 17 docker image that supports both pg-vector and postgis: https://github.com/payloadcms/postgis-vector
image: ${{ (startsWith(matrix.database, 'postgres') ) && 'ghcr.io/payloadcms/postgis-vector:latest' || '' }}
env:
# must specify password for PG Docker container image, see: https://registry.hub.docker.com/_/postgres?tab=description&page=1&name=10
POSTGRES_USER: ${{ env.POSTGRES_USER }}
@@ -258,6 +261,7 @@ jobs:
needs: [changes, build]
if: ${{ needs.changes.outputs.needs_tests == 'true' }}
name: e2e-${{ matrix.suite }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -312,6 +316,7 @@ jobs:
- plugin-cloud-storage
- plugin-form-builder
- plugin-import-export
- plugin-multi-tenant
- plugin-nested-docs
- plugin-seo
- sort
@@ -448,6 +453,7 @@ jobs:
- plugin-cloud-storage
- plugin-form-builder
- plugin-import-export
- plugin-multi-tenant
- plugin-nested-docs
- plugin-seo
- sort
@@ -520,24 +526,32 @@ jobs:
# report-tag: ${{ matrix.suite }}
# job-summary: true
# Build listed templates with packed local packages
build-templates:
# Build listed templates with packed local packages and then runs their int and e2e tests
build-and-test-templates:
runs-on: ubuntu-24.04
needs: build
needs: [changes, build]
if: ${{ needs.changes.outputs.needs_build == 'true' }}
name: build-template-${{ matrix.template }}-${{ matrix.database }}
strategy:
fail-fast: false
matrix:
include:
- template: blank
database: mongodb
- template: website
database: mongodb
- template: with-payload-cloud
database: mongodb
- template: with-vercel-mongodb
database: mongodb
# Postgres
- template: with-postgres
database: postgres
- template: with-vercel-postgres
database: postgres
@@ -547,8 +561,6 @@ jobs:
# - template: with-vercel-website
# database: postgres
name: ${{ matrix.template }}-${{ matrix.database }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@@ -612,6 +624,45 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=8096
- name: Store Playwright's Version
run: |
# Extract the version number using a more targeted regex pattern with awk
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2}')
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: Cache Playwright Browsers for Playwright's Version
id: cache-playwright-browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
- name: Setup Playwright - Browsers and Dependencies
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Setup Playwright - Dependencies-only
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Runs Template Int Tests
run: pnpm --filter ${{ matrix.template }} run test:int
env:
NODE_OPTIONS: --max-old-space-size=8096
PAYLOAD_DATABASE: ${{ matrix.database }}
POSTGRES_URL: ${{ env.POSTGRES_URL }}
MONGODB_URL: mongodb://localhost:27017/payloadtests
- name: Runs Template E2E Tests
run: PLAYWRIGHT_JSON_OUTPUT_NAME=results_${{ matrix.template }}.json pnpm --filter ${{ matrix.template }} test:e2e
env:
NODE_OPTIONS: --max-old-space-size=8096
PAYLOAD_DATABASE: ${{ matrix.database }}
POSTGRES_URL: ${{ env.POSTGRES_URL }}
MONGODB_URL: mongodb://localhost:27017/payloadtests
NEXT_TELEMETRY_DISABLED: 1
tests-type-generation:
runs-on: ubuntu-24.04
needs: [changes, build]
@@ -647,7 +698,7 @@ jobs:
needs:
- lint
- build
- build-templates
- build-and-test-templates
- tests-unit
- tests-int
- tests-e2e

View File

@@ -8,7 +8,7 @@ on:
env:
NODE_VERSION: 23.11.0
PNPM_VERSION: 10.12.1
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

@@ -13,7 +13,7 @@ on:
env:
NODE_VERSION: 23.11.0
PNPM_VERSION: 10.12.1
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

@@ -8,7 +8,7 @@ on:
env:
NODE_VERSION: 23.11.0
PNPM_VERSION: 10.12.1
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

@@ -1,2 +1,2 @@
pnpm 10.12.1
pnpm 9.7.1
nodejs 23.11.0

View File

@@ -6,6 +6,8 @@
"source.fixAll.eslint": "explicit"
},
"editor.formatOnSaveMode": "file",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"eslint.rules.customizations": [
// Silence some warnings that will get auto-fixed
{ "rule": "perfectionist/*", "severity": "off", "fixable": true },

View File

@@ -45,7 +45,7 @@ There are a couple ways to do this:
- **Granularly** - you can run individual tests in vscode by installing the Jest Runner plugin and using that to run individual tests. Clicking the `debug` button will run the test in debug mode allowing you to set break points.
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/int-debug.png" />
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/int-debug.png" />
- **Manually** - you can run all int tests in the `/test/_community/int.spec.ts` file by running the following command:
@@ -62,7 +62,7 @@ The easiest way to run E2E tests is to install
Once they are installed you can open the `testing` tab in vscode sidebar and drill down to the test you want to run, i.e. `/test/_community/e2e.spec.ts`
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/e2e-debug.png" />
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/e2e-debug.png" />
#### Notes

View File

@@ -32,18 +32,18 @@ The Admin Panel serves as the entire HTTP layer for Payload, providing a full CR
Once you [install Payload](../getting-started/installation), the following files and directories will be created in your app:
```plaintext
app/
├─ (payload)/
├── admin/
├─── [[...segments]]/
app
├─ (payload)
├── admin
├─── [[...segments]]
├──── page.tsx
├──── not-found.tsx
├── api/
├─── [...slug]/
├── api
├─── [...slug]
├──── route.ts
├── graphql/
├── graphql
├──── route.ts
├── graphql-playground/
├── graphql-playground
├──── route.ts
├── custom.scss
├── layout.tsx
@@ -84,29 +84,30 @@ import { buildConfig } from 'payload'
const config = buildConfig({
// ...
// highlight-start
admin: {
// highlight-line
// ...
},
// highlight-end
})
```
The following options are available:
| Option | Description |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **`avatar`** | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| **`autoLogin`** | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). |
| **`components`** | Component overrides that affect the entirety of the Admin Panel. [More details](../custom-components/overview). |
| **`custom`** | Any custom properties you wish to pass to the Admin Panel. |
| **`dateFormat`** | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
| **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). |
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
| **`suppressHydrationWarning`** | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root `<html>` tag. Defaults to `false`. |
| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. |
| **`timezones`** | Configure the timezone settings for the admin panel. [More details](#timezones) |
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
| Option | Description |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `autoLogin` | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). |
| `components` | Component overrides that affect the entirety of the Admin Panel. [More details](../custom-components/overview). |
| `custom` | Any custom properties you wish to pass to the Admin Panel. |
| `dateFormat` | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
| `meta` | Base metadata to use for the Admin Panel. [More details](./metadata). |
| `routes` | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
| `suppressHydrationWarning` | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root `<html>` tag. Defaults to `false`. |
| `theme` | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. |
| `timezones` | Configure the timezone settings for the admin panel. [More details](#timezones) |
| `user` | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
<Banner type="success">
**Reminder:** These are the _root-level_ options for the Admin Panel. You can
@@ -186,6 +187,12 @@ The following options are available:
| `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. |
| `graphQLPlayground` | `/graphql-playground` | The GraphQL Playground. |
<Banner type="warning">
**Important:** Changing Root-level Routes also requires a change to [Project
Structure](#project-structure) to match the new route. [More
details](#customizing-root-level-routes).
</Banner>
<Banner type="success">
**Tip:** You can easily add _new_ routes to the Admin Panel through [Custom
Endpoints](../rest-api/overview#custom-endpoints) and [Custom
@@ -196,13 +203,29 @@ The following options are available:
You can change the Root-level Routes as needed, such as to mount the Admin Panel at the root of your application.
Changing Root-level Routes also requires a change to [Project Structure](#project-structure) to match the new route. For example, if you set `routes.admin` to `/`, you would need to completely remove the `admin` directory from the project structure:
This change, however, also requires a change to your [Project Structure](#project-structure) to match the new route.
For example, if you set `routes.admin` to `/`:
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
routes: {
admin: '/', // highlight-line
},
})
```
Then you would need to completely remove the `admin` directory from the project structure:
```plaintext
app/
├─ (payload)/
├── [[...segments]]/
app
├─ (payload)
├── [[...segments]]
├──── ...
├── layout.tsx
```
<Banner type="warning">

View File

@@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a
**Example REST API logout**:
```ts
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
const res = await fetch(
'http://localhost:3000/api/[collection-slug]/logout?allSessions=false',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
})
)
```
**Example GraphQL Mutation**:
```
mutation {
logout[collection-singular-label]
logoutUser(allSessions: false)
}
```
@@ -203,6 +206,10 @@ mutation {
docs](../local-api/server-functions#reusable-payload-server-functions).
</Banner>
#### Logging out with sessions enabled
By default, logging out will only end the session pertaining to the JWT that was used to log out with. However, you can pass `allSessions: true` to the logout operation in order to end all sessions for the user logging out.
## Refresh
Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user.

View File

@@ -91,6 +91,7 @@ The following options are available:
| **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). |
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |
| **`useSessions`** | True by default. Set to `false` to use stateless JWTs for authentication instead of sessions. |
| **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). |
### Login With Username
@@ -201,3 +202,43 @@ API Keys can be enabled on auth collections. These are particularly useful when
### Custom Strategies
There are cases where these may not be enough for your application. Payload is extendable by design so you can wire up your own strategy when you need to. [More details](./custom-strategies).
### Access Control
Default auth fields including `email`, `username`, and `password` can be overridden by defining a custom field with the same name in your collection config. This allows you to customize the field — including access control — while preserving the underlying auth functionality. For example, you might want to restrict the `email` field from being updated once it is created, or only allow it to be read by certain user roles. You can achieve this by redefining the field and setting access rules accordingly.
Here's an example of how to restrict access to default auth fields:
```ts
import type { CollectionConfig } from 'payload'
export const Auth: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{
name: 'email', // or 'username'
type: 'text',
access: {
create: () => true,
read: () => false,
update: () => false,
},
},
{
name: 'password', // this will be applied to all password-related fields including new password, confirm password.
type: 'text',
hidden: true, // needed only for the password field to prevent duplication in the Admin panel
access: {
update: () => false,
},
},
],
}
```
**Note:**
- Access functions will apply across the application — I.e. if `read` access is disabled on `email`, it will not appear in the Admin panel UI or API.
- Restricting `read` on the `email` or `username` disables the **Unlock** action in the Admin panel as this function requires access to a user-identifying field.
- When overriding the `password` field, you may need to include `hidden: true` to prevent duplicate fields being displayed in the Admin panel.

View File

@@ -51,7 +51,7 @@ For more granular control, pass a configuration object instead. Payload exposes
| Property | Description |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Component` \* | Pass in the component path that should be rendered when a user navigates to this route. |
| `path` \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. |
| `path` \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. Must begin with a forward slash (`/`). |
| `exact` | Boolean. When true, will only match if the path matches the `usePathname()` exactly. |
| `strict` | When true, a path that has a trailing slash will only match a `location.pathname` with a trailing slash. This has no effect when there are additional URL segments in the pathname. |
| `sensitive` | When true, will match if the path is case sensitive. |

View File

@@ -30,7 +30,6 @@ export const MyCollectionOrGlobalConfig: CollectionConfig = {
// - api
// - versions
// - version
// - livePreview
// - [key: string]
// See below for more details
},

View File

@@ -41,6 +41,7 @@ export default buildConfig({
| `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). |
| `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. |
| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. |
| `disableFallbackSort` | Set to `true` to disable the adapter adding a fallback sort when sorting by non-unique fields, this can affect performance in some cases but it ensures a consistent order of results. |
## Access to Mongoose models

View File

@@ -315,7 +315,8 @@ import type { Field } from 'payload'
export const MyField: Field = {
type: 'text',
name: 'myField',
validate: (value, {req: { t }}) => Boolean(value) || t('validation:required'), // highlight-line
validate: (value, { req: { t } }) =>
Boolean(value) || t('validation:required'), // highlight-line
}
```
@@ -350,7 +351,6 @@ import {
code,
date,
email,
group,
json,
number,
point,

View File

@@ -16,14 +16,15 @@ The labels you provide for your Collections and Globals are used to name the Gra
At the top of your Payload Config you can define all the options to manage GraphQL.
| Option | Description |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `mutations` | Any custom Mutations to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
| `queries` | Any custom Queries to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
| `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) |
| `disablePlaygroundInProduction` | A boolean that if false will enable the GraphQL playground, defaults to true. [More](/docs/graphql/overview#graphql-playground) |
| `disable` | A boolean that if true will disable the GraphQL entirely, defaults to false. |
| `validationRules` | A function that takes the ExecutionArgs and returns an array of ValidationRules. |
| Option | Description |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mutations` | Any custom Mutations to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
| `queries` | Any custom Queries to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
| `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) |
| `disablePlaygroundInProduction` | A boolean that if false will enable the GraphQL playground in production environments, defaults to true. [More](/docs/graphql/overview#graphql-playground) |
| `disableIntrospectionInProduction` | A boolean that if false will enable the GraphQL introspection in production environments, defaults to true. |
| `disable` | A boolean that if true will disable the GraphQL entirely, defaults to false. |
| `validationRules` | A function that takes the ExecutionArgs and returns an array of ValidationRules. |
## Collections

View File

@@ -393,7 +393,7 @@ export default function LoginForm() {
### Logout
Logs out the current user by clearing the authentication cookie.
Logs out the current user by clearing the authentication cookie and current sessions.
#### Importing the `logout` function
@@ -401,7 +401,7 @@ Logs out the current user by clearing the authentication cookie.
import { logout } from '@payloadcms/next/auth'
```
Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below.
Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below. To ensure all sessions are cleared, set `allSessions: true` in the options, if you wish to logout but keep current sessions active, you can set this to `false` or leave it `undefined`.
```ts
'use server'
@@ -411,7 +411,7 @@ import config from '@payload-config'
export async function logoutAction() {
try {
return await logout({ config })
return await logout({ allSessions: true, config })
} catch (error) {
throw new Error(
`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -434,7 +434,7 @@ export default function LogoutButton() {
### Refresh
Refreshes the authentication token for the logged-in user.
Refreshes the authentication token and current session for the logged-in user.
#### Importing the `refresh` function
@@ -453,7 +453,6 @@ import config from '@payload-config'
export async function refreshAction() {
try {
return await refresh({
collection: 'users', // pass your collection slug
config,
})
} catch (error) {

View File

@@ -74,11 +74,7 @@ import * as Sentry from '@sentry/nextjs'
const config = buildConfig({
collections: [Pages, Media],
plugins: [
sentryPlugin({
Sentry,
}),
],
plugins: [sentryPlugin({ Sentry })],
})
export default config
@@ -100,9 +96,7 @@ export default buildConfig({
pool: { connectionString: process.env.DATABASE_URL },
pg, // Inject the patched pg driver for Sentry instrumentation
}),
plugins: [
sentryPlugin({ Sentry }),
],
plugins: [sentryPlugin({ Sentry })],
})
```

View File

@@ -109,6 +109,7 @@ _An asterisk denotes that an option is required._
| **`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`. |
| **`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. |
@@ -435,6 +436,24 @@ export const Media: CollectionConfig = {
}
```
You can also adjust server-side fetching at the upload level as well, this does not effect the `CORS` policy like the `pasteURL` option does, but it allows you to skip the safe fetch check for specific URLs.
```
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
skipSafeFetch: [
{
hostname: 'example.com',
pathname: '/images/*',
},
],
},
}
```
##### Accepted Values for `pasteURL`
| Option | Description |

View File

@@ -1,5 +1,5 @@
{
"name": "website",
"name": "astro-website",
"version": "0.0.1",
"type": "module",
"scripts": {

View File

@@ -14,12 +14,12 @@ export const Header = () => {
<picture>
<source
media="(prefers-color-scheme: dark)"
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
/>
<Image
alt="Payload Logo"
height={30}
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-dark.svg"
width={150}
/>
</picture>

View File

@@ -27,12 +27,12 @@ export const Header = async () => {
<picture>
<source
media="(prefers-color-scheme: dark)"
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
/>
<Image
alt="Payload Logo"
height={30}
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-dark.svg"
width={150}
/>
</picture>

View File

@@ -2,6 +2,7 @@
import { cn } from '@/utilities/ui'
import useClickableCard from '@/utilities/useClickableCard'
import Link from 'next/link'
import { useLocale } from 'next-intl'
import React, { Fragment } from 'react'
import type { Post } from '@/payload-types'
@@ -16,6 +17,7 @@ export const Card: React.FC<{
showCategories?: boolean
title?: string
}> = (props) => {
const locale = useLocale()
const { card, link } = useClickableCard({})
const { className, doc, relationTo, showCategories, title: titleFromProps } = props
@@ -25,7 +27,7 @@ export const Card: React.FC<{
const hasCategories = categories && Array.isArray(categories) && categories.length > 0
const titleToUse = titleFromProps || title
const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space
const href = `/${relationTo}/${slug}`
const href = `/${locale}/${relationTo}/${slug}`
return (
<article

View File

@@ -6,7 +6,7 @@ export const Logo = () => {
<img
alt="Payload Logo"
className="max-w-[9.375rem] invert dark:invert-0"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
/>
)
}

View File

@@ -21,7 +21,7 @@ export async function Footer({ locale }: { locale: TypedLocale }) {
<img
alt="Payload Logo"
className="max-w-[6rem] invert-0"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
/>
</picture>
</Link>

View File

@@ -53,7 +53,7 @@ export default buildConfig({
admin: {
components: {
// The `BeforeLogin` component renders a message that you see while logging into your admin panel.
// Feel free to delete this at any time. Simply remove the line below and the import `BeforeLogin` statement on line 15.
// Feel free to delete this at any time. Simply remove the line below.
beforeLogin: ['@/components/BeforeLogin'],
afterDashboard: ['@/components/AfterDashboard'],
},

View File

@@ -14,9 +14,12 @@ export const superAdminOrTenantAdminAccess: Access = ({ req }) => {
return true
}
return {
tenant: {
in: getUserTenantIDs(req.user, 'tenant-admin'),
},
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
const requestedTenant = req?.data?.tenant
if (requestedTenant && adminTenantAccessIDs.includes(requestedTenant)) {
return true
}
return false
}

View File

@@ -1,6 +1,6 @@
import type { Access } from 'payload'
import type { User } from '../../../payload-types'
import type { Tenant, User } from '../../../payload-types'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
@@ -14,9 +14,20 @@ export const createAccess: Access<User> = ({ req }) => {
return true
}
if (!isSuperAdmin(req.user) && req.data?.roles?.includes('super-admin')) {
return false
}
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
if (adminTenantAccessIDs.length) {
const requestedTenants: Tenant['id'][] =
req.data?.tenants?.map((t: { tenant: Tenant['id'] }) => t.tenant) ?? []
const hasAccessToAllRequestedTenants = requestedTenants.every((tenantID) =>
adminTenantAccessIDs.includes(tenantID),
)
if (hasAccessToAllRequestedTenants) {
return true
}

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.43.0",
"version": "3.45.0",
"private": true,
"type": "module",
"workspaces": [
@@ -11,11 +11,11 @@
"bf": "pnpm run build:force",
"build": "pnpm run build:core",
"build:admin-bar": "turbo build --filter \"@payloadcms/admin-bar\"",
"build:all": "turbo build",
"build:all": "turbo build --filter \"!blank\" --filter \"!website\"",
"build:app": "next build",
"build:app:analyze": "cross-env ANALYZE=true next build",
"build:clean": "pnpm clean:build",
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\"",
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\" --filter \"!blank\" --filter \"!website\"",
"build:core:force": "pnpm clean:build && pnpm build:core --no-cache --force",
"build:create-payload-app": "turbo build --filter create-payload-app",
"build:db-mongodb": "turbo build --filter \"@payloadcms/db-mongodb\"",
@@ -79,9 +79,9 @@
"docker:start": "docker compose -f test/docker-compose.yml up -d",
"docker:stop": "docker compose -f test/docker-compose.yml down",
"force:build": "pnpm run build:core:force",
"lint": "turbo run lint --log-order=grouped --continue",
"lint": "turbo run lint --log-order=grouped --continue --filter \"!blank\" --filter \"!website\"",
"lint-staged": "lint-staged",
"lint:fix": "turbo run lint:fix --log-order=grouped --continue",
"lint:fix": "turbo run lint:fix --log-order=grouped --continue --filter \"!blank\" --filter \"!website\"",
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
"prepare": "husky",
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
@@ -151,10 +151,9 @@
"create-payload-app": "workspace:*",
"cross-env": "7.0.3",
"dotenv": "16.4.7",
"drizzle-kit": "0.31.0",
"drizzle-orm": "0.43.1",
"drizzle-kit": "0.31.4",
"drizzle-orm": "0.44.2",
"escape-html": "^1.0.3",
"eslint": "9.22.0",
"execa": "5.1.1",
"form-data": "3.0.1",
"fs-extra": "10.1.0",
@@ -167,7 +166,7 @@
"next": "15.3.2",
"open": "^10.1.0",
"p-limit": "^5.0.0",
"pg": "8.11.3",
"pg": "8.16.3",
"playwright": "1.50.0",
"playwright-core": "1.50.0",
"prettier": "3.5.3",
@@ -182,13 +181,13 @@
"tempy": "1.0.1",
"tstyche": "^3.1.1",
"tsx": "4.19.2",
"turbo": "^2.3.3",
"turbo": "^2.5.4",
"typescript": "5.7.3"
},
"packageManager": "pnpm@10.12.1",
"packageManager": "pnpm@9.7.1",
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^10.12.1"
"pnpm": "^9.7.0"
},
"pnpm": {
"overrides": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
"version": "3.43.0",
"version": "3.45.0",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

@@ -7,7 +7,7 @@ import path from 'path'
import type { CliArgs, DbType, ProjectExample, ProjectTemplate } from '../types.js'
import { createProject } from './create-project.js'
import { createProject, updatePackageJSONDependencies } from './create-project.js'
import { dbReplacements } from './replacements.js'
import { getValidTemplates } from './templates.js'
@@ -179,5 +179,37 @@ describe('createProject', () => {
expect(content).toContain(dbReplacement.configReplacement().join('\n'))
})
})
describe('updates package.json', () => {
it('updates package name and bumps workspace versions', async () => {
const latestVersion = '3.0.0'
const initialJSON = {
name: 'test-project',
version: '1.0.0',
dependencies: {
'@payloadcms/db-mongodb': 'workspace:*',
payload: 'workspace:*',
'@payloadcms/ui': 'workspace:*',
},
}
const correctlyModifiedJSON = {
name: 'test-project',
version: '1.0.0',
dependencies: {
'@payloadcms/db-mongodb': `${latestVersion}`,
payload: `${latestVersion}`,
'@payloadcms/ui': `${latestVersion}`,
},
}
updatePackageJSONDependencies({
latestVersion,
packageJson: initialJSON,
})
expect(initialJSON).toEqual(correctlyModifiedJSON)
})
})
})
})

View File

@@ -129,7 +129,11 @@ export async function createProject(
const spinner = p.spinner()
spinner.start('Checking latest Payload version...')
await updatePackageJSON({ projectDir, projectName })
const payloadVersion = await getLatestPackageVersion({ packageName: 'payload' })
spinner.stop(`Found latest version of Payload ${payloadVersion}`)
await updatePackageJSON({ latestVersion: payloadVersion, projectDir, projectName })
if ('template' in args) {
if (args.template.type === 'plugin') {
@@ -177,17 +181,105 @@ export async function createProject(
}
}
/**
* Reads the package.json file into an object and then does the following:
* - Sets the `name` property to the provided `projectName`.
* - Bumps the payload packages from workspace:* to the latest version.
* - Writes the updated object back to the package.json file.
*/
export async function updatePackageJSON(args: {
/**
* The latest version of Payload to use in the package.json.
*/
latestVersion: string
projectDir: string
/**
* The name of the project to set in package.json.
*/
projectName: string
}): Promise<void> {
const { projectDir, projectName } = args
const { latestVersion, projectDir, projectName } = args
const packageJsonPath = path.resolve(projectDir, 'package.json')
try {
const packageObj = await fse.readJson(packageJsonPath)
packageObj.name = projectName
updatePackageJSONDependencies({
latestVersion,
packageJson: packageObj,
})
await fse.writeJson(packageJsonPath, packageObj, { spaces: 2 })
} catch (err: unknown) {
warning(`Unable to update name in package.json. ${err instanceof Error ? err.message : ''}`)
}
}
/**
* Recursively updates a JSON object to replace all instances of `workspace:` with the latest version pinned.
*
* Does not return and instead modifies the `packageJson` object in place.
*/
export function updatePackageJSONDependencies(args: {
latestVersion: string
packageJson: Record<string, unknown>
}): void {
const { latestVersion, packageJson } = args
const updatedDependencies = Object.entries(packageJson.dependencies || {}).reduce(
(acc, [key, value]) => {
if (typeof value === 'string' && value.startsWith('workspace:')) {
acc[key] = `${latestVersion}`
} else if (key === 'payload' || key.startsWith('@payloadcms')) {
acc[key] = `${latestVersion}`
} else {
acc[key] = value
}
return acc
},
{} as Record<string, string>,
)
packageJson.dependencies = updatedDependencies
}
/**
* Fetches the latest version of a package from the NPM registry.
*
* Used in determining the latest version of Payload to use in the generated templates.
*/
async function getLatestPackageVersion({
packageName = 'payload',
}: {
/**
* Package name to fetch the latest version for based on the NPM registry URL
*
* Eg. for `'payload'`, it will fetch the version from `https://registry.npmjs.org/payload`
*
* @default 'payload'
*/
packageName?: string
}): Promise<string> {
try {
const response = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`)
const data = await response.json()
// Monster chaining for type safety just checking for data.latest
const latestVersion =
data &&
typeof data === 'object' &&
'latest' in data &&
data.latest &&
typeof data.latest === 'string'
? data.latest
: null
if (!latestVersion) {
throw new Error(`No latest version found for package: ${packageName}`)
}
return latestVersion
} catch (error) {
console.error('Error fetching Payload version:', error)
throw error
}
}

View File

@@ -17,7 +17,7 @@ export async function downloadTemplate({
}) {
const branchOrTag = template.url.split('#')?.[1] || 'latest'
const url = `https://codeload.github.com/payloadcms/payload/tar.gz/${branchOrTag}`
const filter = `payload-${branchOrTag.replace(/^v/, '')}/templates/${template.name}/`
const filter = `payload-${branchOrTag.replace(/^v/, '').replaceAll('/', '-')}/templates/${template.name}/`
if (debug) {
debugLog(`Using template url: ${template.url}`)

View File

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

View File

@@ -118,6 +118,13 @@ export interface Args {
*/
useFacet?: boolean
} & ConnectOptions
/**
* We add a secondary sort based on `createdAt` to ensure that results are always returned in the same order when sorting by a non-unique field.
* This is because MongoDB does not guarantee the order of results, however in very large datasets this could affect performance.
*
* Set to `true` to disable this behaviour.
*/
disableFallbackSort?: boolean
/** Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false */
disableIndexHints?: boolean
/**
@@ -131,6 +138,7 @@ export interface Args {
*/
mongoMemoryServer?: MongoMemoryReplSet
prodMigrations?: Migration[]
transactionOptions?: false | TransactionOptions
/** The URL to connect to MongoDB or false to start payload and prevent connecting */
@@ -198,6 +206,7 @@ export function mongooseAdapter({
autoPluralization = true,
collectionsSchemaOptions = {},
connectOptions,
disableFallbackSort = false,
disableIndexHints = false,
ensureIndexes = false,
migrationDir: migrationDirArg,
@@ -251,6 +260,7 @@ export function mongooseAdapter({
deleteOne,
deleteVersions,
destroy,
disableFallbackSort,
find,
findGlobal,
findGlobalVersions,

View File

@@ -0,0 +1,121 @@
import type { Config, SanitizedConfig } from 'payload'
import { sanitizeConfig } from 'payload'
import { buildSortParam } from './buildSortParam.js'
import { MongooseAdapter } from '../index.js'
let config: SanitizedConfig
describe('builds sort params', () => {
beforeAll(async () => {
config = await sanitizeConfig({
localization: {
defaultLocale: 'en',
fallback: true,
locales: ['en', 'es'],
},
} as Config)
})
it('adds a fallback on non-unique field', () => {
const result = buildSortParam({
config,
parentIsLocalized: false,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'order',
type: 'number',
},
],
locale: 'en',
sort: 'order',
timestamps: true,
adapter: {
disableFallbackSort: false,
} as MongooseAdapter,
})
expect(result).toStrictEqual({ order: 'asc', createdAt: 'desc' })
})
it('adds a fallback when sort isnt provided', () => {
const result = buildSortParam({
config,
parentIsLocalized: false,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'order',
type: 'number',
},
],
locale: 'en',
sort: undefined,
timestamps: true,
adapter: {
disableFallbackSort: false,
} as MongooseAdapter,
})
expect(result).toStrictEqual({ createdAt: 'desc' })
})
it('does not add a fallback on non-unique field when disableFallbackSort is true', () => {
const result = buildSortParam({
config,
parentIsLocalized: false,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'order',
type: 'number',
},
],
locale: 'en',
sort: 'order',
timestamps: true,
adapter: {
disableFallbackSort: true,
} as MongooseAdapter,
})
expect(result).toStrictEqual({ order: 'asc' })
})
// This test should be true even when disableFallbackSort is false
it('does not add a fallback on unique field', () => {
const result = buildSortParam({
config,
parentIsLocalized: false,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'order',
type: 'number',
unique: true, // Marking this field as unique
},
],
locale: 'en',
sort: 'order',
timestamps: true,
adapter: {
disableFallbackSort: false,
} as MongooseAdapter,
})
expect(result).toStrictEqual({ order: 'asc' })
})
})

View File

@@ -19,7 +19,7 @@ type Args = {
fields: FlattenedField[]
locale?: string
parentIsLocalized?: boolean
sort: Sort
sort?: Sort
sortAggregation?: PipelineStage[]
timestamps: boolean
versions?: boolean
@@ -77,6 +77,9 @@ const relationshipSort = ({
) {
const relationshipPath = segments.slice(0, i + 1).join('.')
let sortFieldPath = segments.slice(i + 1, segments.length).join('.')
if (sortFieldPath.endsWith('.id')) {
sortFieldPath = sortFieldPath.split('.').slice(0, -1).join('.')
}
if (Array.isArray(field.relationTo)) {
throw new APIError('Not supported')
}
@@ -150,6 +153,12 @@ export const buildSortParam = ({
sort = [sort]
}
// We use this flag to determine if the sort is unique or not to decide whether to add a fallback sort.
const isUniqueSort = sort.some((item) => {
const field = getFieldByPath({ fields, path: item })
return field?.field?.unique
})
// In the case of Mongo, when sorting by a field that is not unique, the results are not guaranteed to be in the same order each time.
// So we add a fallback sort to ensure that the results are always in the same order.
let fallbackSort = '-id'
@@ -158,7 +167,12 @@ export const buildSortParam = ({
fallbackSort = '-createdAt'
}
if (!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))) {
const includeFallbackSort =
!adapter.disableFallbackSort &&
!isUniqueSort &&
!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))
if (includeFallbackSort) {
sort.push(fallbackSort)
}

View File

@@ -55,6 +55,7 @@ export const updateOne: UpdateOne = async function updateOne(
try {
if (returning === false) {
await Model.updateOne(query, data, options)
transform({ adapter: this, data, fields, operation: 'read' })
return null
} else {
result = await Model.findOneAndUpdate(query, data, options)

View File

@@ -277,7 +277,9 @@ const stripFields = ({
continue
}
for (const data of localeData) {
let hasNull = false
for (let i = 0; i < localeData.length; i++) {
const data = localeData[i]
let fields: FlattenedField[] | null = null
if (field.type === 'array') {
@@ -286,11 +288,17 @@ const stripFields = ({
let maybeBlock: FlattenedBlock | undefined = undefined
if (field.blockReferences) {
const maybeBlockReference = field.blockReferences.find(
(each) => typeof each === 'object' && each.slug === data.blockType,
)
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
maybeBlock = maybeBlockReference
const maybeBlockReference = field.blockReferences.find((each) => {
const slug = typeof each === 'string' ? each : each.slug
return slug === data.blockType
})
if (maybeBlockReference) {
if (typeof maybeBlockReference === 'object') {
maybeBlock = maybeBlockReference
} else {
maybeBlock = config.blocks?.find((each) => each.slug === maybeBlockReference)
}
}
}
@@ -300,6 +308,9 @@ const stripFields = ({
if (maybeBlock) {
fields = maybeBlock.flattenedFields
} else {
localeData[i] = null
hasNull = true
}
}
@@ -310,6 +321,10 @@ const stripFields = ({
stripFields({ config, data, fields, reservedKeys })
}
if (hasNull) {
fieldData[localeKey] = localeData.filter(Boolean)
}
continue
} else {
stripFields({ config, data: localeData, fields: field.flattenedFields, reservedKeys })
@@ -323,7 +338,10 @@ const stripFields = ({
continue
}
for (const data of fieldData) {
let hasNull = false
for (let i = 0; i < fieldData.length; i++) {
const data = fieldData[i]
let fields: FlattenedField[] | null = null
if (field.type === 'array') {
@@ -332,12 +350,17 @@ const stripFields = ({
let maybeBlock: FlattenedBlock | undefined = undefined
if (field.blockReferences) {
const maybeBlockReference = field.blockReferences.find(
(each) => typeof each === 'object' && each.slug === data.blockType,
)
const maybeBlockReference = field.blockReferences.find((each) => {
const slug = typeof each === 'string' ? each : each.slug
return slug === data.blockType
})
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
maybeBlock = maybeBlockReference
if (maybeBlockReference) {
if (typeof maybeBlockReference === 'object') {
maybeBlock = maybeBlockReference
} else {
maybeBlock = config.blocks?.find((each) => each.slug === maybeBlockReference)
}
}
}
@@ -347,6 +370,9 @@ const stripFields = ({
if (maybeBlock) {
fields = maybeBlock.flattenedFields
} else {
fieldData[i] = null
hasNull = true
}
}
@@ -357,6 +383,10 @@ const stripFields = ({
stripFields({ config, data, fields, reservedKeys })
}
if (hasNull) {
data[field.name] = fieldData.filter(Boolean)
}
continue
} else {
stripFields({ config, data: fieldData, fields: field.flattenedFields, reservedKeys })
@@ -387,7 +417,7 @@ export const transform = ({
if (operation === 'read') {
delete data['__v']
data.id = data._id
data.id = data._id || data.id
delete data['_id']
if (data.id instanceof Types.ObjectId) {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.43.0",
"version": "3.45.0",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -78,9 +78,9 @@
"@payloadcms/drizzle": "workspace:*",
"@types/pg": "8.10.2",
"console-table-printer": "2.12.1",
"drizzle-kit": "0.31.1",
"drizzle-kit": "0.31.4",
"drizzle-orm": "0.44.2",
"pg": "8.11.3",
"pg": "8.16.3",
"prompts": "2.4.2",
"to-snake-case": "1.0.0",
"uuid": "10.0.0"

View File

@@ -37,6 +37,7 @@ import {
updateMany,
updateOne,
updateVersion,
upsert,
} from '@payloadcms/drizzle'
import {
columnToCodeConverter,
@@ -207,7 +208,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
updateMany,
updateOne,
updateVersion,
upsert: updateOne,
upsert,
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.43.0",
"version": "3.45.0",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -76,7 +76,7 @@
"@libsql/client": "0.14.0",
"@payloadcms/drizzle": "workspace:*",
"console-table-printer": "2.12.1",
"drizzle-kit": "0.31.1",
"drizzle-kit": "0.31.4",
"drizzle-orm": "0.44.2",
"prompts": "2.4.2",
"to-snake-case": "1.0.0",

View File

@@ -38,6 +38,7 @@ import {
updateMany,
updateOne,
updateVersion,
upsert,
} from '@payloadcms/drizzle'
import { like, notLike } from 'drizzle-orm'
import { createDatabaseAdapter, defaultBeginTransaction } from 'payload'
@@ -189,7 +190,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
updateGlobalVersion,
updateOne,
updateVersion,
upsert: updateOne,
upsert,
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.43.0",
"version": "3.45.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -78,9 +78,9 @@
"@payloadcms/drizzle": "workspace:*",
"@vercel/postgres": "^0.9.0",
"console-table-printer": "2.12.1",
"drizzle-kit": "0.31.1",
"drizzle-kit": "0.31.4",
"drizzle-orm": "0.44.2",
"pg": "8.11.3",
"pg": "8.16.3",
"prompts": "2.4.2",
"to-snake-case": "1.0.0",
"uuid": "10.0.0"

View File

@@ -38,6 +38,7 @@ import {
updateMany,
updateOne,
updateVersion,
upsert,
} from '@payloadcms/drizzle'
import {
columnToCodeConverter,
@@ -202,7 +203,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
updateMany,
updateOne,
updateVersion,
upsert: updateOne,
upsert,
})
}

View File

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

View File

@@ -80,7 +80,7 @@ export const findMany = async function find({
if (orderBy) {
for (const key in selectFields) {
const column = selectFields[key]
if (column.primary) {
if (!column || column.primary) {
continue
}

View File

@@ -513,7 +513,7 @@ export const traverseFields = ({
const subQueryAlias = `${columnName}_subquery`
let sqlWhere = eq(
adapter.tables[currentTableName].id,
sql.raw(`"${currentTableName}"."id"`),
sql.raw(`"${subQueryAlias}"."${onPath}"`),
)
@@ -577,19 +577,23 @@ export const traverseFields = ({
let joinQueryWhere: Where
const currentIDRaw = sql.raw(
`"${getNameFromDrizzleTable(currentIDColumn.table)}"."${currentIDColumn.name}"`,
)
if (Array.isArray(field.targetField.relationTo)) {
joinQueryWhere = {
[field.on]: {
equals: {
relationTo: collectionSlug,
value: rawConstraint(currentIDColumn),
value: rawConstraint(currentIDRaw),
},
},
}
} else {
joinQueryWhere = {
[field.on]: {
equals: rawConstraint(currentIDColumn),
equals: rawConstraint(currentIDRaw),
},
}
}

View File

@@ -78,6 +78,7 @@ export { updateJobs } from './updateJobs.js'
export { updateMany } from './updateMany.js'
export { updateOne } from './updateOne.js'
export { updateVersion } from './updateVersion.js'
export { upsert } from './upsert.js'
export { upsertRow } from './upsertRow/index.js'
export { buildCreateMigration } from './utilities/buildCreateMigration.js'
export { buildIndexName } from './utilities/buildIndexName.js'

View File

@@ -24,20 +24,26 @@ export const columnToCodeConverter: ColumnToCodeConverter = ({
const columnBuilderArgsArray: string[] = []
if (column.type === 'timestamp') {
columnBuilderArgsArray.push(`mode: '${column.mode}'`)
if (column.withTimezone) {
columnBuilderArgsArray.push('withTimezone: true')
switch (column.type) {
case 'bit':
case 'halfvec':
case 'sparsevec':
case 'vector': {
if (column.dimensions) {
columnBuilderArgsArray.push(`dimensions: ${column.dimensions}`)
}
break
}
case 'timestamp': {
columnBuilderArgsArray.push(`mode: '${column.mode}'`)
if (column.withTimezone) {
columnBuilderArgsArray.push('withTimezone: true')
}
if (typeof column.precision === 'number') {
columnBuilderArgsArray.push(`precision: ${column.precision}`)
}
}
if (column.type === 'vector') {
if (column.dimensions) {
columnBuilderArgsArray.push(`dimensions: ${column.dimensions}`)
if (typeof column.precision === 'number') {
columnBuilderArgsArray.push(`precision: ${column.precision}`)
}
break
}
}

View File

@@ -1,13 +1,16 @@
import type { ForeignKeyBuilder, IndexBuilder } from 'drizzle-orm/pg-core'
import {
bit,
boolean,
foreignKey,
halfvec,
index,
integer,
jsonb,
numeric,
serial,
sparsevec,
text,
timestamp,
uniqueIndex,
@@ -44,6 +47,14 @@ export const buildDrizzleTable = ({
for (const [key, column] of Object.entries(rawTable.columns)) {
switch (column.type) {
case 'bit': {
const builder = bit(column.name, { dimensions: column.dimensions })
columns[key] = builder
break
}
case 'enum':
if ('locale' in column) {
columns[key] = adapter.enums.enum__locales(column.name)
@@ -56,6 +67,21 @@ export const buildDrizzleTable = ({
}
break
case 'halfvec': {
const builder = halfvec(column.name, { dimensions: column.dimensions })
columns[key] = builder
break
}
case 'sparsevec': {
const builder = sparsevec(column.name, { dimensions: column.dimensions })
columns[key] = builder
break
}
case 'timestamp': {
let builder = timestamp(column.name, {
mode: column.mode,

View File

@@ -53,6 +53,7 @@ type Args = {
fields: FlattenedField[]
joins: BuildQueryJoinAliases
locale?: string
parentAliasTable?: PgTableWithColumns<any> | SQLiteTableWithColumns<any>
parentIsLocalized: boolean
pathSegments: string[]
rootTableName?: string
@@ -83,6 +84,7 @@ export const getTableColumnFromPath = ({
fields,
joins,
locale: incomingLocale,
parentAliasTable,
parentIsLocalized,
pathSegments: incomingSegments,
rootTableName: incomingRootTableName,
@@ -162,6 +164,7 @@ export const getTableColumnFromPath = ({
table: adapter.tables[newTableName],
})
}
return getTableColumnFromPath({
adapter,
collectionPath,
@@ -170,6 +173,7 @@ export const getTableColumnFromPath = ({
fields: field.flattenedFields,
joins,
locale,
parentAliasTable: aliasTable,
parentIsLocalized: parentIsLocalized || field.localized,
pathSegments: pathSegments.slice(1),
rootTableName,
@@ -548,7 +552,10 @@ export const getTableColumnFromPath = ({
// Join in the relationships table
if (locale && isFieldLocalized && adapter.payload.config.localization) {
const conditions = [
eq((aliasTable || adapter.tables[rootTableName]).id, aliasRelationshipTable.parent),
eq(
(parentAliasTable || aliasTable || adapter.tables[rootTableName]).id,
aliasRelationshipTable.parent,
),
like(aliasRelationshipTable.path, `${constraintPath}${field.name}`),
]
@@ -566,7 +573,10 @@ export const getTableColumnFromPath = ({
// Join in the relationships table
addJoinTable({
condition: and(
eq((aliasTable || adapter.tables[rootTableName]).id, aliasRelationshipTable.parent),
eq(
(parentAliasTable || aliasTable || adapter.tables[rootTableName]).id,
aliasRelationshipTable.parent,
),
like(aliasRelationshipTable.path, `${constraintPath}${field.name}`),
),
joins,
@@ -799,9 +809,10 @@ export const getTableColumnFromPath = ({
`${tableName}_${tableNameSuffix}${toSnakeCase(field.name)}`,
)
const idColumn = (aliasTable ?? adapter.tables[tableName]).id
if (locale && isFieldLocalized && adapter.payload.config.localization) {
const conditions = [
eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
eq(idColumn, adapter.tables[newTableName].parent),
eq(adapter.tables[newTableName]._locale, locale),
]
@@ -816,7 +827,7 @@ export const getTableColumnFromPath = ({
})
} else {
addJoinTable({
condition: eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
condition: eq(idColumn, adapter.tables[newTableName].parent),
joins,
table: adapter.tables[newTableName],
})

View File

@@ -281,12 +281,30 @@ export type VectorRawColumn = {
type: 'vector'
} & BaseRawColumn
export type HalfVecRawColumn = {
dimensions?: number
type: 'halfvec'
} & BaseRawColumn
export type SparseVecRawColumn = {
dimensions?: number
type: 'sparsevec'
} & BaseRawColumn
export type BinaryVecRawColumn = {
dimensions?: number
type: 'bit'
} & BaseRawColumn
export type RawColumn =
| ({
type: 'boolean' | 'geometry' | 'jsonb' | 'numeric' | 'serial' | 'text' | 'varchar'
} & BaseRawColumn)
| BinaryVecRawColumn
| EnumRawColumn
| HalfVecRawColumn
| IntegerRawColumn
| SparseVecRawColumn
| TimestampRawColumn
| UUIDRawColumn
| VectorRawColumn

View File

@@ -18,6 +18,7 @@ export const updateOne: UpdateOne = async function updateOne(
data,
joins: joinQuery,
locale,
options = { upsert: false },
req,
returning,
select,
@@ -66,6 +67,13 @@ export const updateOne: UpdateOne = async function updateOne(
}
}
if (!idToUpdate && !options.upsert) {
// TODO: In 4.0, if returning === false, we should differentiate between:
// - No document found to update
// - Document found, but returning === false
return null
}
const result = await upsertRow({
id: idToUpdate,
adapter: this,

View File

@@ -0,0 +1,20 @@
import type { Upsert } from 'payload'
import type { DrizzleAdapter } from './types.js'
export const upsert: Upsert = async function upsert(
this: DrizzleAdapter,
{ collection, data, joins, locale, req, returning, select, where },
) {
return this.updateOne({
collection,
data,
joins,
locale,
options: { upsert: true },
req,
returning,
select,
where,
})
}

View File

@@ -13,6 +13,13 @@ import { deleteExistingArrayRows } from './deleteExistingArrayRows.js'
import { deleteExistingRowsByPath } from './deleteExistingRowsByPath.js'
import { insertArrays } from './insertArrays.js'
/**
* If `id` is provided, it will update the row with that ID.
* If `where` is provided, it will update the row that matches the `where`
* If neither `id` nor `where` is provided, it will create a new row.
*
* This function replaces the entire row and does not support partial updates.
*/
export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>({
id,
adapter,

View File

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

View File

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

View File

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

View File

@@ -113,6 +113,7 @@ export function configToSchema(config: SanitizedConfig): {
variables: args.variableValues,
// onComplete: (complexity) => { console.log('Query Complexity:', complexity); },
}),
...(config.graphQL.disableIntrospectionInProduction ? [NoProductionIntrospection] : []),
...(typeof config?.graphQL?.validationRules === 'function'
? config.graphQL.validationRules(args)
: []),
@@ -123,3 +124,18 @@ export function configToSchema(config: SanitizedConfig): {
validationRules,
}
}
const NoProductionIntrospection: GraphQL.ValidationRule = (context) => ({
Field(node) {
if (process.env.NODE_ENV === 'production') {
if (node.name.value === '__schema' || node.name.value === '__type') {
context.reportError(
new GraphQL.GraphQLError(
'GraphQL introspection is not allowed, but the query contained __schema or __type',
{ nodes: [node] },
),
)
}
}
},
})

View File

@@ -7,6 +7,7 @@ import type { Context } from '../types.js'
export function logout(collection: Collection): any {
async function resolver(_, args, context: Context) {
const options = {
allSessions: args.allSessions,
collection,
req: isolateObjectProperty(context.req, 'transactionID'),
}

View File

@@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
graphqlResult.Mutation.fields[`logout${singularName}`] = {
type: GraphQLString,
args: {
allSessions: { type: GraphQLBoolean },
},
resolve: logout(collection),
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.43.0",
"version": "3.45.0",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.43.0",
"version": "3.45.0",
"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.43.0",
"version": "3.45.0",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -67,5 +67,9 @@ export const handleMessage = async <T extends Record<string, any>>(args: {
return mergedData
}
return initialData
if (!_payloadLivePreview.previousData) {
_payloadLivePreview.previousData = initialData
}
return _payloadLivePreview.previousData as T
}

View File

@@ -84,7 +84,7 @@ export const mergeData = async <T extends Record<string, any>>(args: {
res = await requestHandler({
apiPath: apiRoute || '/api',
endpoint: encodeURI(
`${collection}?depth=${depth}&where[id][in]=${Array.from(ids).join(',')}${locale ? `&locale=${locale}` : ''}`,
`${collection}?depth=${depth}&limit=${ids.size}&where[id][in]=${Array.from(ids).join(',')}${locale ? `&locale=${locale}` : ''}`,
),
serverURL,
}).then((res) => res.json())

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.43.0",
"version": "3.45.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -22,6 +22,10 @@
],
"type": "module",
"exports": {
"./css": {
"import": "./src/dummy.css",
"default": "./src/dummy.css"
},
".": {
"import": "./src/index.js",
"types": "./src/index.js",

View File

@@ -2,8 +2,7 @@
import type { CollectionSlug } from 'payload'
import { cookies as getCookies } from 'next/headers.js'
import { generatePayloadCookie, getPayload } from 'payload'
import { getPayload } from 'payload'
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
@@ -31,6 +30,7 @@ export async function login({ collection, config, email, password, username }: L
const payload = await getPayload({ config })
const authConfig = payload.collections[collection]?.config.auth
if (!authConfig) {
throw new Error(`No auth config found for collection: ${collection}`)
}
@@ -61,27 +61,22 @@ export async function login({ collection, config, email, password, username }: L
loginData = { email, password }
}
try {
const result = await payload.login({
collection,
data: loginData,
const result = await payload.login({
collection,
data: loginData,
})
if (result.token) {
await setPayloadAuthCookie({
authConfig,
cookiePrefix: payload.config.cookiePrefix,
token: result.token,
})
if (result.token) {
await setPayloadAuthCookie({
authConfig,
cookiePrefix: payload.config.cookiePrefix,
token: result.token,
})
}
if ('removeTokenFromResponses' in config && config.removeTokenFromResponses) {
delete result.token
}
return result
} catch (e) {
console.error('Login error:', e)
throw new Error(`${e}`)
}
if ('removeTokenFromResponses' in config && config.removeTokenFromResponses) {
delete result.token
}
return result
}

View File

@@ -1,29 +1,46 @@
'use server'
import type { SanitizedConfig } from 'payload'
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
import { getPayload } from 'payload'
import { createLocalReq, getPayload, logoutOperation } from 'payload'
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
export async function logout({ config }: { config: any }) {
try {
const payload = await getPayload({ config })
const headers = await nextHeaders()
const result = await payload.auth({ headers })
export async function logout({
allSessions = false,
config,
}: {
allSessions?: boolean
config: Promise<SanitizedConfig> | SanitizedConfig
}) {
const payload = await getPayload({ config })
const headers = await nextHeaders()
const authResult = await payload.auth({ headers })
if (!result.user) {
return { message: 'User already logged out', success: true }
}
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
if (existingCookie) {
const cookies = await getCookies()
cookies.delete(existingCookie.name)
return { message: 'User logged out successfully', success: true }
}
} catch (e) {
console.error('Logout error:', e)
throw new Error(`${e}`)
if (!authResult.user) {
return { message: 'User already logged out', success: true }
}
const { user } = authResult
const req = await createLocalReq({ user }, payload)
const collection = payload.collections[user.collection]
const logoutResult = await logoutOperation({
allSessions,
collection,
req,
})
if (!logoutResult) {
return { message: 'Logout failed', success: false }
}
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
if (existingCookie) {
const cookies = await getCookies()
cookies.delete(existingCookie.name)
}
return { message: 'User logged out successfully', success: true }
}

View File

@@ -3,40 +3,48 @@
import type { CollectionSlug } from 'payload'
import { headers as nextHeaders } from 'next/headers.js'
import { getPayload } from 'payload'
import { createLocalReq, getPayload, refreshOperation } from 'payload'
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) {
try {
const payload = await getPayload({ config })
const authConfig = payload.collections[collection]?.config.auth
export async function refresh({ config }: { config: any }) {
const payload = await getPayload({ config })
const headers = await nextHeaders()
const result = await payload.auth({ headers })
if (!authConfig) {
throw new Error(`No auth config found for collection: ${collection}`)
}
const { user } = await payload.auth({ headers: await nextHeaders() })
if (!user) {
throw new Error('User not authenticated')
}
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
if (!existingCookie) {
return { message: 'No valid token found', success: false }
}
await setPayloadAuthCookie({
authConfig,
cookiePrefix: payload.config.cookiePrefix,
token: existingCookie.value,
})
return { message: 'Token refreshed successfully', success: true }
} catch (e) {
console.error('Refresh error:', e)
throw new Error(`${e}`)
if (!result.user) {
throw new Error('Cannot refresh token: user not authenticated')
}
const collection: CollectionSlug | undefined = result.user.collection
const collectionConfig = payload.collections[collection]
if (!collectionConfig?.config.auth) {
throw new Error(`No auth config found for collection: ${collection}`)
}
const req = await createLocalReq({ user: result.user }, payload)
const refreshResult = await refreshOperation({
collection: collectionConfig,
req,
})
if (!refreshResult) {
return { message: 'Token refresh failed', success: false }
}
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
if (!existingCookie) {
return { message: 'No valid token found to refresh', success: false }
}
await setPayloadAuthCookie({
authConfig: collectionConfig.config.auth,
cookiePrefix: payload.config.cookiePrefix,
token: existingCookie.value,
})
return { message: 'Token refreshed successfully', success: true }
}

View File

@@ -28,32 +28,6 @@ export const getTabs = ({
},
viewPath: '/',
},
{
tab: {
condition: ({ collectionConfig, config, globalConfig }) => {
if (collectionConfig) {
return Boolean(
config?.admin?.livePreview?.collections?.includes(collectionConfig.slug) ||
collectionConfig?.admin?.livePreview,
)
}
if (globalConfig) {
return Boolean(
config?.admin?.livePreview?.globals?.includes(globalConfig.slug) ||
globalConfig?.admin?.livePreview,
)
}
return false
},
href: '/preview',
label: ({ t }) => t('general:livePreview'),
order: 200,
...(customViews?.['livePreview']?.tab || {}),
},
viewPath: '/preview',
},
{
tab: {
condition: ({ collectionConfig, globalConfig, permissions }) =>

View File

@@ -146,7 +146,7 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
return (
<EntityVisibilityProvider visibleEntities={visibleEntities}>
<BulkUploadProvider>
<BulkUploadProvider drawerSlugPrefix={collectionSlug}>
<ActionsProvider Actions={Actions}>
{RenderServerComponent({
clientProps,

View File

@@ -1,4 +1,4 @@
import type { User } from 'payload'
import type { TypedUser } from 'payload'
import { formatAdminURL } from 'payload/shared'
import * as qs from 'qs-esm'
@@ -7,7 +7,7 @@ type Args = {
config
route: string
searchParams: { [key: string]: string | string[] }
user?: User
user?: TypedUser
}
export const handleAuthRedirect = ({ config, route, searchParams, user }: Args): string => {

View File

@@ -6,7 +6,7 @@ import type {
PayloadRequest,
SanitizedConfig,
SanitizedPermissions,
User,
TypedUser,
} from 'payload'
import { initI18n } from '@payloadcms/translations'
@@ -37,7 +37,7 @@ type PartialResult = {
languageCode: AcceptedLanguages
payload: Payload
responseHeaders: Headers
user: null | User
user: null | TypedUser
}
// Create cache instances for different parts of our application

View File

@@ -1,5 +1,5 @@
'use client'
import type { User } from 'payload'
import type { TypedUser } from 'payload'
import { Button, ConfirmationModal, toast, useModal, useTranslation } from '@payloadcms/ui'
import * as qs from 'qs-esm'
@@ -9,7 +9,7 @@ const confirmResetModalSlug = 'confirm-reset-modal'
export const ResetPreferences: React.FC<{
readonly apiRoute: string
readonly user?: User
readonly user?: TypedUser
}> = ({ apiRoute, user }) => {
const { openModal } = useModal()
const { t } = useTranslation()

View File

@@ -1,5 +1,5 @@
import type { I18n } from '@payloadcms/translations'
import type { BasePayload, Config, LanguageOptions, User } from 'payload'
import type { BasePayload, Config, LanguageOptions, TypedUser } from 'payload'
import { FieldLabel } from '@payloadcms/ui'
import React from 'react'
@@ -17,7 +17,7 @@ export const Settings: React.FC<{
readonly languageOptions: LanguageOptions
readonly payload: BasePayload
readonly theme: Config['admin']['theme']
readonly user?: User
readonly user?: TypedUser
}> = (props) => {
const { className, i18n, languageOptions, payload, theme, user } = props

View File

@@ -12,7 +12,6 @@ import type { ViewToRender } from './index.js'
import { APIView as DefaultAPIView } from '../API/index.js'
import { EditView as DefaultEditView } from '../Edit/index.js'
import { LivePreviewView as DefaultLivePreviewView } from '../LivePreview/index.js'
import { UnauthorizedViewWithGutter } from '../Unauthorized/index.js'
import { VersionView as DefaultVersionView } from '../Version/index.js'
import { VersionsView as DefaultVersionsView } from '../Versions/index.js'
@@ -112,7 +111,6 @@ export const getDocumentView = ({
}
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/<custom-segment>
case 4: {
@@ -125,17 +123,6 @@ export const getDocumentView = ({
break
}
case 'preview': {
// --> /collections/:collectionSlug/:id/preview
if (
(collectionConfig && collectionConfig?.admin?.livePreview) ||
config?.admin?.livePreview?.collections?.includes(collectionConfig?.slug)
) {
View = getCustomViewByKey(views, 'livePreview') || DefaultLivePreviewView
}
break
}
case 'versions': {
// --> /collections/:collectionSlug/:id/versions
if (docPermissions?.readVersions) {
@@ -234,7 +221,6 @@ export const getDocumentView = ({
case 3: {
// --> /globals/:globalSlug/api
// --> /globals/:globalSlug/preview
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/<custom-segment>
switch (segment3) {
@@ -247,18 +233,6 @@ export const getDocumentView = ({
break
}
case 'preview': {
// --> /globals/:globalSlug/preview
if (
(globalConfig && globalConfig?.admin?.livePreview) ||
config?.admin?.livePreview?.globals?.includes(globalConfig?.slug)
) {
View = getCustomViewByKey(views, 'livePreview') || DefaultLivePreviewView
}
break
}
case 'versions': {
// --> /globals/:globalSlug/versions
if (docPermissions?.readVersions) {

View File

@@ -6,7 +6,6 @@ import type { GenerateViewMetadata } from '../Root/index.js'
import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { generateAPIViewMetadata } from '../API/metadata.js'
import { generateEditViewMetadata } from '../Edit/metadata.js'
import { generateLivePreviewViewMetadata } from '../LivePreview/metadata.js'
import { generateNotFoundViewMetadata } from '../NotFound/metadata.js'
import { generateVersionViewMetadata } from '../Version/metadata.js'
import { generateVersionsViewMetadata } from '../Versions/metadata.js'
@@ -50,10 +49,6 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
// `/:collection/:id/api`
fn = generateAPIViewMetadata
break
case 'preview':
// `/:collection/:id/preview`
fn = generateLivePreviewViewMetadata
break
case 'versions':
// `/:collection/:id/versions`
fn = generateVersionsViewMetadata
@@ -89,10 +84,6 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
// `/:global/api`
fn = generateAPIViewMetadata
break
case 'preview':
// `/:global/preview`
fn = generateLivePreviewViewMetadata
break
case 'versions':
// `/:global/versions`
fn = generateVersionsViewMetadata

View File

@@ -1,15 +1,22 @@
import type {
AdminViewServerProps,
CollectionPreferences,
Data,
DocumentViewClientProps,
DocumentViewServerProps,
DocumentViewServerPropsOnly,
EditViewComponent,
LivePreviewConfig,
PayloadComponent,
RenderDocumentVersionsProperties,
} from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import {
DocumentInfoProvider,
EditDepthProvider,
HydrateAuthProvider,
LivePreviewProvider,
} from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
@@ -21,6 +28,7 @@ import React from 'react'
import type { GenerateEditViewMetadata } from './getMetaBySegment.js'
import { DocumentHeader } from '../../elements/DocumentHeader/index.js'
import { getPreferences } from '../../utilities/getPreferences.js'
import { NotFoundView } from '../NotFound/index.js'
import { getDocPreferences } from './getDocPreferences.js'
import { getDocumentData } from './getDocumentData.js'
@@ -119,6 +127,7 @@ export const renderDocument = async ({
docPreferences,
{ docPermissions, hasPublishPermission, hasSavePermission },
{ currentEditor, isLocked, lastUpdateTime },
entityPreferences,
] = await Promise.all([
// Get document preferences
getDocPreferences({
@@ -146,8 +155,18 @@ export const renderDocument = async ({
isEditing,
req,
}),
// get entity preferences
getPreferences<CollectionPreferences>(
collectionSlug ? `collection-${collectionSlug}` : `global-${globalSlug}`,
payload,
req.user.id,
req.user.collection,
),
])
const operation = (collectionSlug && idFromArgs) || globalSlug ? 'update' : 'create'
const [
{ hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount },
{ state: formState },
@@ -171,7 +190,7 @@ export const renderDocument = async ({
fallbackLocale: false,
globalSlug,
locale: locale?.code,
operation: (collectionSlug && idFromArgs) || globalSlug ? 'update' : 'create',
operation,
renderAllFields: true,
req,
schemaPath: collectionSlug || globalSlug,
@@ -310,6 +329,28 @@ export const renderDocument = async ({
viewType,
}
const livePreviewConfig: LivePreviewConfig = {
...(config.admin.livePreview || {}),
...(collectionConfig?.admin?.livePreview || {}),
...(globalConfig?.admin?.livePreview || {}),
}
const livePreviewURL =
typeof livePreviewConfig?.url === 'function'
? await livePreviewConfig.url({
collectionConfig,
data: doc,
globalConfig,
locale,
req,
/**
* @deprecated
* Use `req.payload` instead. This will be removed in the next major version.
*/
payload: initPageResult.req.payload,
})
: livePreviewConfig?.url
return {
data: doc,
Document: (
@@ -337,24 +378,31 @@ export const renderDocument = async ({
unpublishedVersionCount={unpublishedVersionCount}
versionCount={versionCount}
>
{showHeader && !drawerSlug && (
<DocumentHeader
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
/>
)}
<HydrateAuthProvider permissions={permissions} />
<EditDepthProvider>
{RenderServerComponent({
clientProps,
Component: View,
importMap,
serverProps: documentViewServerProps,
})}
</EditDepthProvider>
<LivePreviewProvider
breakpoints={livePreviewConfig?.breakpoints}
isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'}
operation={operation}
url={livePreviewURL}
>
{showHeader && !drawerSlug && (
<DocumentHeader
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
/>
)}
<HydrateAuthProvider permissions={permissions} />
<EditDepthProvider>
{RenderServerComponent({
clientProps,
Component: View,
importMap,
serverProps: documentViewServerProps,
})}
</EditDepthProvider>
</LivePreviewProvider>
</DocumentInfoProvider>
),
}

View File

@@ -1,4 +1,4 @@
import type { ListPreferences, ListQuery, ServerFunction, VisibleEntities } from 'payload'
import type { CollectionPreferences, ListQuery, ServerFunction, VisibleEntities } from 'payload'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders } from 'next/headers.js'
@@ -8,7 +8,7 @@ import { renderListView } from './index.js'
type RenderListResult = {
List: React.ReactNode
preferences: ListPreferences
preferences: CollectionPreferences
}
export const renderListHandler: ServerFunction<
@@ -92,7 +92,7 @@ export const renderListHandler: ServerFunction<
importMap: payload.importMap,
})
const preferencesKey = `${collectionSlug}-list`
const preferencesKey = `collection-${collectionSlug}`
const preferences = await payload
.find({
@@ -119,7 +119,7 @@ export const renderListHandler: ServerFunction<
],
},
})
.then((res) => res.docs[0]?.value as ListPreferences)
.then((res) => res.docs[0]?.value as CollectionPreferences)
const visibleEntities: VisibleEntities = {
collections: payload.config.collections

View File

@@ -1,8 +1,8 @@
import type {
AdminViewServerProps,
CollectionPreferences,
ColumnPreference,
DefaultDocumentIDType,
ListPreferences,
ListQuery,
ListViewClientProps,
ListViewServerPropsOnly,
@@ -98,8 +98,8 @@ export const renderListView = async (
* This will ensure that prefs are only updated when explicitly set by the user
* This could potentially be done by injecting a `sessionID` into the params and comparing it against a session cookie
*/
const listPreferences = await upsertPreferences<ListPreferences>({
key: `${collectionSlug}-list`,
const collectionPreferences = await upsertPreferences<CollectionPreferences>({
key: `collection-${collectionSlug}`,
req,
value: {
columns,
@@ -120,10 +120,10 @@ export const renderListView = async (
const page = isNumber(query?.page) ? Number(query.page) : 0
const limit = listPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
const limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
const sort =
listPreferences?.sort ||
collectionPreferences?.sort ||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined)
let where = mergeListSearchAndWhere({
@@ -150,10 +150,10 @@ export const renderListView = async (
let queryPreset: QueryPreset | undefined
let queryPresetPermissions: SanitizedCollectionPermission | undefined
if (listPreferences?.preset) {
if (collectionPreferences?.preset) {
try {
queryPreset = (await payload.findByID({
id: listPreferences?.preset,
id: collectionPreferences?.preset,
collection: 'payload-query-presets',
depth: 0,
overrideAccess: false,
@@ -194,7 +194,7 @@ export const renderListView = async (
const { columnState, Table } = renderTable({
clientCollectionConfig,
collectionConfig,
columnPreferences: listPreferences?.columns,
columnPreferences: collectionPreferences?.columns,
columns,
customCellProps,
docs: data.docs,
@@ -230,7 +230,7 @@ export const renderListView = async (
data,
i18n,
limit,
listPreferences,
listPreferences: collectionPreferences,
listSearchableFields: collectionConfig.admin.listSearchableFields,
locale: fullLocale,
params,
@@ -264,7 +264,7 @@ export const renderListView = async (
data={data}
defaultLimit={limit}
defaultSort={sort}
listPreferences={listPreferences}
listPreferences={collectionPreferences}
modifySearchParams={!isInDrawer}
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
>
@@ -278,7 +278,7 @@ export const renderListView = async (
disableQueryPresets,
enableRowSelections,
hasCreatePermission,
listPreferences,
listPreferences: collectionPreferences,
newDocumentURL,
queryPreset,
queryPresetPermissions,

View File

@@ -1,629 +0,0 @@
'use client'
import type { FormProps } from '@payloadcms/ui'
import type {
ClientCollectionConfig,
ClientConfig,
ClientField,
ClientGlobalConfig,
ClientUser,
Data,
DocumentSlots,
FormState,
LivePreviewConfig,
} from 'payload'
import {
DocumentControls,
DocumentFields,
DocumentLocked,
DocumentTakeOver,
Form,
LeaveWithoutSaving,
OperationProvider,
SetDocumentStepNav,
SetDocumentTitle,
useAuth,
useConfig,
useDocumentDrawerContext,
useDocumentEvents,
useDocumentInfo,
useEditDepth,
useRouteTransition,
useServerFunctions,
useTranslation,
useUploadEdits,
} from '@payloadcms/ui'
import {
abortAndIgnore,
handleAbortRef,
handleBackToDashboard,
handleGoBack,
handleTakeOver,
} from '@payloadcms/ui/shared'
import { useRouter, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useLivePreviewContext } from './Context/context.js'
import './index.scss'
import { LivePreviewProvider } from './Context/index.js'
import { LivePreview } from './Preview/index.js'
import { usePopupWindow } from './usePopupWindow.js'
const baseClass = 'live-preview'
type Props = {
readonly apiRoute: string
readonly collectionConfig?: ClientCollectionConfig
readonly config: ClientConfig
readonly fields: ClientField[]
readonly globalConfig?: ClientGlobalConfig
readonly schemaPath: string
readonly serverURL: string
} & DocumentSlots
const getAbsoluteUrl = (url) => {
try {
return new URL(url, window.location.origin).href
} catch {
return url
}
}
const PreviewView: React.FC<Props> = ({
BeforeDocumentControls,
collectionConfig,
config,
Description,
EditMenuItems,
fields,
globalConfig,
PreviewButton,
PublishButton,
SaveButton,
SaveDraftButton,
schemaPath,
}) => {
const {
id,
action,
AfterDocument,
AfterFields,
apiURL,
BeforeFields,
collectionSlug,
currentEditor,
disableActions,
disableLeaveWithoutSaving,
docPermissions,
documentIsLocked,
getDocPermissions,
getDocPreferences,
globalSlug,
hasPublishPermission,
hasSavePermission,
incrementVersionCount,
initialData,
initialState,
isEditing,
isInitializing,
lastUpdateTime,
setCurrentEditor,
setDocumentIsLocked,
unlockDocument,
updateDocumentEditor,
updateSavedDocumentData,
} = useDocumentInfo()
const { onSave: onSaveFromContext } = useDocumentDrawerContext()
const operation = id ? 'update' : 'create'
const {
config: {
admin: { user: userSlug },
routes: { admin: adminRoute },
},
} = useConfig()
const router = useRouter()
const params = useSearchParams()
const locale = params.get('locale')
const { t } = useTranslation()
const { previewWindowType } = useLivePreviewContext()
const { refreshCookieAsync, user } = useAuth()
const { reportUpdate } = useDocumentEvents()
const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions()
const { startRouteTransition } = useRouteTransition()
const docConfig = collectionConfig || globalConfig
const entitySlug = collectionConfig?.slug || globalConfig?.slug
const depth = useEditDepth()
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
const isLockingEnabled = lockDocumentsProp !== false
const lockDurationDefault = 300 // Default 5 minutes in seconds
const lockDuration =
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
const autosaveEnabled = Boolean(
(collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave),
)
const preventLeaveWithoutSaving =
typeof disableLeaveWithoutSaving !== 'undefined' ? !disableLeaveWithoutSaving : !autosaveEnabled
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const abortOnChangeRef = useRef<AbortController>(null)
const abortOnSaveRef = useRef<AbortController>(null)
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
const isLockExpired = Date.now() > lockExpiryTime
const documentLockStateRef = useRef<{
hasShownLockedModal: boolean
isLocked: boolean
user: ClientUser | number | string
} | null>({
hasShownLockedModal: false,
isLocked: false,
user: null,
})
const onSave = useCallback(
async (json): Promise<FormState> => {
const controller = handleAbortRef(abortOnSaveRef)
reportUpdate({
id,
entitySlug,
updatedAt: json?.result?.updatedAt || new Date().toISOString(),
})
// If we're editing the doc of the logged-in user,
// Refresh the cookie to get new permissions
if (user && collectionSlug === userSlug && id === user.id) {
void refreshCookieAsync()
}
incrementVersionCount()
if (typeof updateSavedDocumentData === 'function') {
void updateSavedDocumentData(json?.doc || {})
}
if (typeof onSaveFromContext === 'function') {
void onSaveFromContext({
...json,
operation: id ? 'update' : 'create',
})
}
if (!isEditing && depth < 2) {
// Redirect to the same locale if it's been set
const redirectRoute = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
})
startRouteTransition(() => router.push(redirectRoute))
} else {
resetUploadEdits()
}
await getDocPermissions(json)
if ((id || globalSlug) && !autosaveEnabled) {
const docPreferences = await getDocPreferences()
const { state } = await getFormState({
id,
collectionSlug,
data: json?.doc || json?.result,
docPermissions,
docPreferences,
globalSlug,
operation,
renderAllFields: true,
returnLockStatus: false,
schemaPath: entitySlug,
signal: controller.signal,
skipValidation: true,
})
// Unlock the document after save
if (isLockingEnabled) {
setDocumentIsLocked(false)
}
abortOnSaveRef.current = null
return state
}
},
[
adminRoute,
collectionSlug,
depth,
docPermissions,
entitySlug,
getDocPermissions,
getDocPreferences,
getFormState,
globalSlug,
id,
incrementVersionCount,
isEditing,
isLockingEnabled,
locale,
onSaveFromContext,
operation,
refreshCookieAsync,
reportUpdate,
resetUploadEdits,
router,
setDocumentIsLocked,
updateSavedDocumentData,
startRouteTransition,
user,
userSlug,
autosaveEnabled,
],
)
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState, submitted }) => {
const controller = handleAbortRef(abortOnChangeRef)
const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - editSessionStartTime
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
if (updateLastEdited) {
setEditSessionStartTime(currentTime)
}
const docPreferences = await getDocPreferences()
const { lockedState, state } = await getFormState({
id,
collectionSlug,
docPermissions,
docPreferences,
formState: prevFormState,
globalSlug,
operation,
returnLockStatus: isLockingEnabled ? true : false,
schemaPath,
signal: controller.signal,
skipValidation: !submitted,
updateLastEdited,
})
setDocumentIsLocked(true)
if (isLockingEnabled) {
const previousOwnerID =
typeof documentLockStateRef.current?.user === 'object'
? documentLockStateRef.current?.user?.id
: documentLockStateRef.current?.user
if (lockedState) {
const lockedUserID =
typeof lockedState.user === 'string' || typeof lockedState.user === 'number'
? lockedState.user
: lockedState.user.id
if (!documentLockStateRef.current || lockedUserID !== previousOwnerID) {
if (previousOwnerID === user.id && lockedUserID !== user.id) {
setShowTakeOverModal(true)
documentLockStateRef.current.hasShownLockedModal = true
}
documentLockStateRef.current = documentLockStateRef.current = {
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal || false,
isLocked: true,
user: lockedState.user as ClientUser,
}
setCurrentEditor(lockedState.user as ClientUser)
}
}
}
abortOnChangeRef.current = null
return state
},
[
editSessionStartTime,
isLockingEnabled,
getDocPreferences,
getFormState,
id,
collectionSlug,
docPermissions,
globalSlug,
operation,
schemaPath,
setDocumentIsLocked,
user?.id,
setCurrentEditor,
],
)
// Clean up when the component unmounts or when the document is unlocked
useEffect(() => {
return () => {
if (!isLockingEnabled) {
return
}
const currentPath = window.location.pathname
const documentID = id || globalSlug
// Routes where we do NOT want to unlock the document
const stayWithinDocumentPaths = ['preview', 'api', 'versions']
const isStayingWithinDocument = stayWithinDocumentPaths.some((path) =>
currentPath.includes(path),
)
// Unlock the document only if we're actually navigating away from the document
if (documentID && documentIsLocked && !isStayingWithinDocument) {
// Check if this user is still the current editor
if (
typeof documentLockStateRef.current?.user === 'object'
? documentLockStateRef.current?.user?.id === user?.id
: documentLockStateRef.current?.user === user?.id
) {
void unlockDocument(id, collectionSlug ?? globalSlug)
setDocumentIsLocked(false)
setCurrentEditor(null)
}
}
setShowTakeOverModal(false)
}
}, [
collectionSlug,
globalSlug,
id,
unlockDocument,
user,
setCurrentEditor,
isLockingEnabled,
documentIsLocked,
setDocumentIsLocked,
])
useEffect(() => {
const abortOnChange = abortOnChangeRef.current
const abortOnSave = abortOnSaveRef.current
return () => {
abortAndIgnore(abortOnChange)
abortAndIgnore(abortOnSave)
}
})
const shouldShowDocumentLockedModal =
documentIsLocked &&
currentEditor &&
(typeof currentEditor === 'object'
? currentEditor.id !== user?.id
: currentEditor !== user?.id) &&
!isReadOnlyForIncomingUser &&
!showTakeOverModal &&
// eslint-disable-next-line react-compiler/react-compiler
!documentLockStateRef.current?.hasShownLockedModal &&
!isLockExpired
return (
<OperationProvider operation={operation}>
<Form
action={action}
className={`${baseClass}__form`}
disabled={isReadOnlyForIncomingUser || !hasSavePermission}
initialState={initialState}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
onSuccess={onSave}
>
{isLockingEnabled && shouldShowDocumentLockedModal && !isReadOnlyForIncomingUser && (
<DocumentLocked
handleGoBack={() => handleGoBack({ adminRoute, collectionSlug, router })}
isActive={shouldShowDocumentLockedModal}
onReadOnly={() => {
setIsReadOnlyForIncomingUser(true)
setShowTakeOverModal(false)
}}
onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
false,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
)
}
updatedAt={lastUpdateTime}
user={currentEditor}
/>
)}
{isLockingEnabled && showTakeOverModal && (
<DocumentTakeOver
handleBackToDashboard={() => handleBackToDashboard({ adminRoute, router })}
isActive={showTakeOverModal}
onReadOnly={() => {
setIsReadOnlyForIncomingUser(true)
setShowTakeOverModal(false)
}}
/>
)}
{!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && <LeaveWithoutSaving />}
<SetDocumentStepNav
collectionSlug={collectionSlug}
globalLabel={globalConfig?.label}
globalSlug={globalSlug}
id={id}
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
view={t('general:livePreview')}
/>
<SetDocumentTitle
collectionConfig={collectionConfig}
config={config}
fallback={id?.toString() || ''}
globalConfig={globalConfig}
/>
<DocumentControls
apiURL={apiURL}
BeforeDocumentControls={BeforeDocumentControls}
customComponents={{
PreviewButton,
PublishButton,
SaveButton,
SaveDraftButton,
}}
data={initialData}
disableActions={disableActions}
EditMenuItems={EditMenuItems}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={id}
isEditing={isEditing}
onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
true,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
setIsReadOnlyForIncomingUser,
)
}
permissions={docPermissions}
readOnlyForIncomingUser={isReadOnlyForIncomingUser}
slug={collectionConfig?.slug || globalConfig?.slug}
user={currentEditor}
/>
<div
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
.filter(Boolean)
.join(' ')}
>
<div
className={[
`${baseClass}__main`,
previewWindowType === 'popup' && `${baseClass}__main--popup-open`,
]
.filter(Boolean)
.join(' ')}
>
<DocumentFields
AfterFields={AfterFields}
BeforeFields={BeforeFields}
Description={Description}
docPermissions={docPermissions}
fields={fields}
forceSidebarWrap
readOnly={isReadOnlyForIncomingUser || !hasSavePermission}
schemaPathSegments={[collectionSlug || globalSlug]}
/>
{AfterDocument}
</div>
<LivePreview collectionSlug={collectionSlug} globalSlug={globalSlug} />
</div>
</Form>
</OperationProvider>
)
}
export const LivePreviewClient: React.FC<
{
readonly breakpoints: LivePreviewConfig['breakpoints']
readonly initialData: Data
readonly url: string
} & DocumentSlots
> = (props) => {
const { breakpoints, url: incomingUrl } = props
const { collectionSlug, globalSlug } = useDocumentInfo()
const {
config,
config: {
routes: { api: apiRoute },
serverURL,
},
getEntityConfig,
} = useConfig()
const url =
incomingUrl.startsWith('http://') || incomingUrl.startsWith('https://')
? incomingUrl
: getAbsoluteUrl(incomingUrl)
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
eventType: 'payload-live-preview',
url,
})
const collectionConfig = getEntityConfig({ collectionSlug })
const globalConfig = getEntityConfig({ globalSlug })
const schemaPath = collectionSlug || globalSlug
return (
<Fragment>
<LivePreviewProvider
breakpoints={breakpoints}
fieldSchema={collectionConfig?.fields || globalConfig?.fields}
isPopupOpen={isPopupOpen}
openPopupWindow={openPopupWindow}
popupRef={popupRef}
url={url}
>
<PreviewView
apiRoute={apiRoute}
BeforeDocumentControls={props.BeforeDocumentControls}
collectionConfig={collectionConfig}
config={config}
Description={props.Description}
EditMenuItems={props.EditMenuItems}
fields={(collectionConfig || globalConfig)?.fields}
globalConfig={globalConfig}
PreviewButton={props.PreviewButton}
PublishButton={props.PublishButton}
SaveButton={props.SaveButton}
SaveDraftButton={props.SaveDraftButton}
schemaPath={schemaPath}
serverURL={serverURL}
Upload={props.Upload}
/>
</LivePreviewProvider>
</Fragment>
)
}

View File

@@ -1,105 +0,0 @@
import type {
BeforeDocumentControlsServerPropsOnly,
DocumentViewServerProps,
EditMenuItemsServerPropsOnly,
LivePreviewConfig,
ServerProps,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React from 'react'
import './index.scss'
import { LivePreviewClient } from './index.client.js'
export async function LivePreviewView(props: DocumentViewServerProps) {
const { doc, initPageResult } = props
const { collectionConfig, globalConfig, locale, req } = initPageResult
let livePreviewConfig: LivePreviewConfig = req.payload.config?.admin?.livePreview
const serverProps: ServerProps = {
i18n: req.i18n,
payload: req.payload,
user: req.user,
// TODO: Add remaining serverProps
}
if (collectionConfig) {
livePreviewConfig = {
...(livePreviewConfig || {}),
...(collectionConfig.admin.livePreview || {}),
}
}
if (globalConfig) {
livePreviewConfig = {
...(livePreviewConfig || {}),
...(globalConfig.admin.livePreview || {}),
}
}
const BeforeDocumentControls =
collectionConfig?.admin?.components?.edit?.beforeDocumentControls ||
globalConfig?.admin?.components?.elements?.beforeDocumentControls
const EditMenuItems = collectionConfig?.admin?.components?.edit?.editMenuItems
const breakpoints: LivePreviewConfig['breakpoints'] = [
...(livePreviewConfig?.breakpoints || []),
{
name: 'responsive',
height: '100%',
label: 'Responsive',
width: '100%',
},
]
const url =
typeof livePreviewConfig?.url === 'function'
? await livePreviewConfig.url({
collectionConfig,
data: doc,
globalConfig,
locale,
req,
/**
* @deprecated
* Use `req.payload` instead. This will be removed in the next major version.
*/
payload: initPageResult.req.payload,
})
: livePreviewConfig?.url
return (
<LivePreviewClient
BeforeDocumentControls={
BeforeDocumentControls
? RenderServerComponent({
Component: BeforeDocumentControls,
importMap: req.payload.importMap,
serverProps: serverProps satisfies BeforeDocumentControlsServerPropsOnly,
})
: null
}
breakpoints={breakpoints}
Description={props.Description}
EditMenuItems={
EditMenuItems
? RenderServerComponent({
Component: EditMenuItems,
importMap: req.payload.importMap,
serverProps: serverProps satisfies EditMenuItemsServerPropsOnly,
})
: null
}
initialData={doc}
PreviewButton={props.PreviewButton}
PublishButton={props.PublishButton}
SaveButton={props.SaveButton}
SaveDraftButton={props.SaveDraftButton}
Upload={props.Upload}
url={url}
/>
)
}

View File

@@ -1,21 +0,0 @@
import type { Metadata } from 'next'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { generateEditViewMetadata } from '../Edit/metadata.js'
export const generateLivePreviewViewMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
config,
globalConfig,
i18n,
isEditing,
}): Promise<Metadata> =>
generateEditViewMetadata({
collectionConfig,
config,
globalConfig,
i18n,
isEditing,
view: 'livePreview',
})

View File

@@ -22,9 +22,11 @@ export const getCustomViewByRoute = ({
routes: { admin: adminRoute },
} = config
const currentRoute = currentRouteWithAdmin.replace(adminRoute, '')
let viewKey: string
const currentRoute =
adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '')
const foundViewConfig =
(views &&
typeof views === 'object' &&

View File

@@ -19,11 +19,6 @@ export function getDocumentViewInfo(segments: string[]): {
documentSubViewType: 'versions',
viewType: 'document',
}
} else if (tabSegment === 'preview') {
return {
documentSubViewType: 'livePreview',
viewType: 'document',
}
} else if (tabSegment === 'api') {
return {
documentSubViewType: 'api',

View File

@@ -291,7 +291,6 @@ export const getRouteData = ({
// Collection Edit Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionID
initPageOptions.routeParams.id = segmentThree
@@ -317,7 +316,6 @@ export const getRouteData = ({
} else if (isGlobal && matchedGlobal) {
// Global Edit Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/preview
// --> /globals/:globalSlug/versions/:versionID
// --> /globals/:globalSlug/api
initPageOptions.routeParams.global = matchedGlobal.slug

View File

@@ -144,7 +144,6 @@ export const generatePageMetadata = async ({
} else {
// Collection Document Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
@@ -154,7 +153,6 @@ export const generatePageMetadata = async ({
// Global Document Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/versions/:version
// --> /globals/:globalSlug/preview
// --> /globals/:globalSlug/api
meta = await generateDocumentViewMetadata({
config,

View File

@@ -238,6 +238,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
<SelectComparison
collectionSlug={collectionSlug}
docID={originalDocID}
globalSlug={globalSlug}
onChange={onChangeVersionFrom}
versionFromID={versionFromID}
versionFromOptions={versionFromOptions}

View File

@@ -24,11 +24,12 @@ export const formatVersionDrawerSlug = ({
}) => `version-drawer_${depth}_${uuid}`
export const VersionDrawerContent: React.FC<{
collectionSlug: string
docID: number | string
collectionSlug?: string
docID?: number | string
drawerSlug: string
globalSlug?: string
}> = (props) => {
const { collectionSlug, docID, drawerSlug } = props
const { collectionSlug, docID, drawerSlug, globalSlug } = props
const { closeModal } = useModal()
const searchParams = useSearchParams()
const prevSearchParams = useRef(searchParams)
@@ -46,12 +47,20 @@ export const VersionDrawerContent: React.FC<{
setIsLoading(true)
try {
const isGlobal = Boolean(globalSlug)
const entitySlug = collectionSlug ?? globalSlug
const result = await renderDocument({
collectionSlug,
collectionSlug: entitySlug,
docID,
drawerSlug,
paramsOverride: {
segments: ['collections', collectionSlug, String(docID), 'versions'],
segments: [
isGlobal ? 'globals' : 'collections',
entitySlug,
isGlobal ? undefined : String(docID),
'versions',
].filter(Boolean),
},
redirectAfterDelete: false,
redirectAfterDuplicate: false,
@@ -75,7 +84,7 @@ export const VersionDrawerContent: React.FC<{
void fetchDocumentView()
},
[closeModal, collectionSlug, drawerSlug, renderDocument, searchParams, t],
[closeModal, collectionSlug, globalSlug, drawerSlug, renderDocument, searchParams, t],
)
useEffect(() => {
@@ -93,11 +102,12 @@ export const VersionDrawerContent: React.FC<{
return DocumentView
}
export const VersionDrawer: React.FC<{
collectionSlug: string
docID: number | string
collectionSlug?: string
docID?: number | string
drawerSlug: string
globalSlug?: string
}> = (props) => {
const { collectionSlug, docID, drawerSlug } = props
const { collectionSlug, docID, drawerSlug, globalSlug } = props
const { t } = useTranslation()
return (
@@ -107,7 +117,12 @@ export const VersionDrawer: React.FC<{
slug={drawerSlug}
title={t('version:selectVersionToCompare')}
>
<VersionDrawerContent collectionSlug={collectionSlug} docID={docID} drawerSlug={drawerSlug} />
<VersionDrawerContent
collectionSlug={collectionSlug}
docID={docID}
drawerSlug={drawerSlug}
globalSlug={globalSlug}
/>
</Drawer>
)
}
@@ -115,9 +130,11 @@ export const VersionDrawer: React.FC<{
export const useVersionDrawer = ({
collectionSlug,
docID,
globalSlug,
}: {
collectionSlug: string
docID: number | string
collectionSlug?: string
docID?: number | string
globalSlug?: string
}) => {
const drawerDepth = useEditDepth()
const uuid = useId()
@@ -147,9 +164,14 @@ export const useVersionDrawer = ({
const MemoizedDrawer = useMemo(() => {
return () => (
<VersionDrawer collectionSlug={collectionSlug} docID={docID} drawerSlug={drawerSlug} />
<VersionDrawer
collectionSlug={collectionSlug}
docID={docID}
drawerSlug={drawerSlug}
globalSlug={globalSlug}
/>
)
}, [collectionSlug, docID, drawerSlug])
}, [collectionSlug, docID, drawerSlug, globalSlug])
return useMemo(
() => ({

View File

@@ -17,13 +17,14 @@ export const SelectComparison: React.FC<Props> = memo((props) => {
const {
collectionSlug,
docID,
globalSlug,
onChange: onChangeFromProps,
versionFromID,
versionFromOptions,
} = props
const { t } = useTranslation()
const { Drawer, openDrawer } = useVersionDrawer({ collectionSlug, docID })
const { Drawer, openDrawer } = useVersionDrawer({ collectionSlug, docID, globalSlug })
const options = useMemo(() => {
return [

View File

@@ -3,8 +3,9 @@ import type { PaginatedDocs, SanitizedCollectionConfig } from 'payload'
import type { CompareOption } from '../Default/types.js'
export type Props = {
collectionSlug: string
docID: number | string
collectionSlug?: string
docID?: number | string
globalSlug?: string
onChange: (val: CompareOption) => void
versionFromID?: string
versionFromOptions: CompareOption[]

View File

@@ -4,8 +4,8 @@ import {
type PayloadRequest,
type SelectType,
type Sort,
type TypedUser,
type TypeWithVersion,
type User,
type Where,
} from 'payload'
@@ -28,7 +28,7 @@ export const fetchVersion = async <TVersionData extends object = object>({
overrideAccess?: boolean
req: PayloadRequest
select?: SelectType
user?: User
user?: TypedUser
}): Promise<null | TypeWithVersion<TVersionData>> => {
try {
if (collectionSlug) {
@@ -88,7 +88,7 @@ export const fetchVersions = async <TVersionData extends object = object>({
req: PayloadRequest
select?: SelectType
sort?: Sort
user?: User
user?: TypedUser
where?: Where
}): Promise<null | PaginatedDocs<TypeWithVersion<TVersionData>>> => {
const where: Where = { and: [...(whereFromArgs ? [whereFromArgs] : [])] }
@@ -160,7 +160,7 @@ export const fetchLatestVersion = async <TVersionData extends object = object>({
req: PayloadRequest
select?: SelectType
status: 'draft' | 'published'
user?: User
user?: TypedUser
where?: Where
}): Promise<null | TypeWithVersion<TVersionData>> => {
const and: Where[] = [

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