Compare commits

..

114 Commits

Author SHA1 Message Date
Germán Jabloñski
ac46c289a1 revert incorrect eslint autfixes 2025-02-28 15:30:46 -03:00
Germán Jabloñski
4c54ccc25a Merge remote-tracking branch 'origin/main' into revert-11133-typescript-strict-plugin 2025-02-28 15:23:39 -03:00
Jacob Fletcher
67c4a20237 fix(next): properly instantiates req.url on localhost (#11455)
The `req.url` property at the page level was not reflective of the
actual URL on localhost. This was because we were passing an
incompatible `url` override into `createLocalReq` (lacking protocol).
This would silently fail to construct the URL object, ultimately losing
the top-level domain on `req.url` as well as the port on `req.origin`
(see #11454).

Closes #11448.
2025-02-28 13:04:26 -05:00
Alessio Gravili
38131ed2c3 feat: ability to cancel jobs (#11409)
This adds new `payload.jobs.cancel` and `payload.jobs.cancelByID` methods that allow you to cancel already-running jobs, or prevent queued jobs from running.

While it's not possible to cancel a function mid-execution, this will stop job execution the next time the job makes a request to the db, which happens after every task.
2025-02-28 17:58:43 +00:00
Patrik
96d1d90e78 fix(ui): use full image url for upload previews instead of thumbnail url (#11435) 2025-02-28 12:47:02 -05:00
Jessica Chowdhury
9e97319c6f fix(ui): locale selector in versions view should remove filtered locales (#11447)
### What?
The `locale selector` in the version comparison view shows all locales
on first load. It does not accomodate the `filterAvailableLocales`
option and shows locales which should be filtered.

### How?
Pass the initial locales through the `filterAvailableLocales` function.

Closes #11408

#### Testing
Use test suite `localization` and the `localized-drafts` collection.
Test added to `test/localization/e2e`.
2025-02-28 17:37:07 +00:00
Jacob Fletcher
a65289c211 fix: ensures req.origin includes port on localhost (#11454)
The `req.origin` property on the `PayloadRequest` object does not
include the port when running on localhost, a requirement of the [HTML
Living Standard](https://html.spec.whatwg.org/#origin). This was because
we were initializing the url with a fallback of `http://localhost` (no
port). When constructed via `new URL()`, the port is unable to be
extracted. This is fixed by using the `host` property off the headers
object, if it exists, which includes the port.

Partial fix for #11448.
2025-02-28 12:26:38 -05:00
Alessio Gravili
4a1b74952f chore(richtext-lexical): improve UploadData jsdocs (#11292) 2025-02-28 12:18:09 -05:00
Martijn Luyckx
8b55e7b51a docs: replace HTML entity ' with literal apostrophe (#11321) 2025-02-28 17:15:00 +00:00
Roy Barber
48e613b61f fix(examples): replace depreciated 'mergeHeaders' import in the MultiTenant example (#11306) 2025-02-28 17:13:46 +00:00
Jessica Chowdhury
428c133033 fix(ui): copyToLocale should not pass id in data, throws error in postgres (#11402) 2025-02-28 12:06:32 -05:00
Alessio Gravili
c8c578f5ef perf(next): reduce initReq calls from 3 to 1 per page load (#11312)
This PR significantly improves performance when navigating through the admin panel by reducing the number of times `initReq` is called. Previously, `initReq`—which handles expensive tasks like initializing Payload and running access control—was called **three times** for a single page load (for the root layout, the root page, and the notFound page).

We initially tried to use React Cache to ensure `initReq` only ran once per request. However, because React Cache performs a shallow object reference check on function arguments, the configuration object we passed (`configPromise`) and the `overrides` object never maintained the same reference, causing the cache to miss.

### What’s Changed

*   **New `getInitReqContainer` Helper**  
    We introduced a helper that provides a stable object reference throughout the entire request. This allows React to properly cache the output, ensuring `initReq` doesn’t get triggered multiple times by mistake.
    
*   **Splitting `initReq` into Two Functions**  
    The `initReq` logic was split into:
    
    *   **`initPartialReq`:** Runs only **once** per request, handling tasks that do not depend on page-level data (e.g., calling `.auth`, which performs a DB request).
    *    **`initReq`:** Runs **twice** (once for Layout+NotFound page and once for main page), handling tasks, most notably access control, that rely on page-level data such as locale or query parameters. The NotFound page will share the same req as the layout page, as it's not localized, and its access control wouldn't need to access page query / url / locale, just like the layout.

* **Remove duplicative logic**
   * Previously, a lot of logic was run in **both** `initReq` **and** the respective page / layout. This was completely unnecessary, as `initReq` was already running that logic. This PR returns the calculated variables from `initReq`, so they don't have to be duplicatively calculated again.

### Performance Gains

*   Previously:
    *   `.auth` call ran **3 times**
    *   Access control ran **3 times**
*   Now:
    *   `.auth` call runs **1 time**
    *   Access control runs **2 times**

This change yields a noticeable performance improvement by cutting down on redundant work.
2025-02-28 09:25:03 -07:00
Alessio Gravili
d53f166476 fix: ensure errors returned from tasks are properly logged (#11443)
Fixes https://github.com/payloadcms/payload/issues/9767

We allow failing a job queue task by returning `{ state: 'failed' }` from the task, instead of throwing an error. However, previously, this threw an error when trying to update the task in the database. Additionally, it was not possible to customize the error message.

This PR fixes that by letting you return `errorMessage` alongside `{ state: 'failed' }`, and by ensuring the error is transformed into proper json before saving it to the `error` column.
2025-02-28 16:00:56 +00:00
Sasha
dfddee2125 fix(storage-*): ensure client handler is always added to the import map, even if the plugin is disabled (#11438)
Ensures that even if you pass `enabled: false` to the storage adapter
options, e.g:
```ts
s3Storage({
  enabled: false,
  collections: {
    [mediaSlug]: true,
  },
  bucket: process.env.S3_BUCKET,
  config: {
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY_ID,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
    },
  },
})
```
the client handler component is added to the import map. This prevents
errors when you use the adapter only on production, but you don't
regenerate the import map before running the build
2025-02-28 16:34:00 +02:00
Patrik
e055565ca8 ci: repro guide to use create-payload-app@latest instead of @beta (#11451)
This PR updates the reproduction guide to reference
`create-payload-app@latest -t blank` instead of `@beta`, ensuring users
follow the latest stable release when setting up a minimal reproduction.
2025-02-28 09:32:09 -05:00
Alessio Gravili
41c7413f59 feat(db-*): add updateMany method to database adapter (#11441)
This PR adds a new `payload.db.updateMany` method, which is a more performant way to update multiple documents compared to using `payload.update`.
2025-02-27 20:30:17 -07:00
Jacob Fletcher
3709950d50 feat: maintains column state in url (#11387)
Maintains column state in the URL. This makes it possible to share
direct links to the list view in a specific column order or active
column state, similar to the behavior of filters. This also makes it
possible to change both the filters and columns in the same rendering
cycle, a requirement of the "list presets" feature being worked on here:
#11330.

For example:

```
?columns=%5B"title"%2C"content"%2C"-updatedAt"%2C"createdAt"%2C"id"%5D
```

The `-` prefix denotes that the column is inactive.

This strategy performs a single round trip to the server, ultimately
simplifying the table columns provider as it no longer needs to request
a newly rendered table for itself. Without this change, column state
would need to be replaced first, followed by a change to the filters.
This would make an unnecessary number of requests to the server and
briefly render the UI in a stale state.

This all happens behind an optimistic update, where the state of the
columns is immediately reflected in the UI while the request takes place
in the background.

Technically speaking, an additional database query in performed compared
to the old strategy, whereas before we'd send the data through the
request to avoid this. But this is a necessary tradeoff and doesn't have
huge performance implications. One could argue that this is actually a
good thing, as the data might have changed in the background which would
not have been reflected in the result otherwise.
2025-02-27 20:00:40 -05:00
Alessio Gravili
6ce5e8b83b perf: disable returning of db operations that don't need the return value (#11437)
If the return value of a db operation is not used, we can pass `returning: false` which will result in the query being executed faster.

See https://github.com/payloadcms/payload/pull/11393
2025-02-28 00:53:23 +00:00
Philipp Meyer
f3844ee533 fix(next): email verification not working due to incorrect token url parsing (#11439)
### What?
This PR reverts a presumably accidental change made in
[b80010b1a1](b80010b1a1),
that broke the email verification feature in v3.24.0 and onwards.
### Why?
Through the missing verify in `const [collectionSlug, verify, token] =
params.segments`, the token value was always the string `verify`
2025-02-28 00:05:05 +00:00
Alessio Gravili
c21dac1b53 perf(db-*): add option to disable returning modified documents in db methods (#11393)
This PR adds a new `returning` option to various db adapter methods. Setting it to `false` where the return value is not used will lead to performance gains, as we don't have to do additional db calls to fetch the updated document and then sanitize it.
2025-02-27 17:40:22 -05:00
Jarrod Flesch
b3e7a9d194 fix: incorrect value inside beforeValidate field hooks (#11433)
### What?
`value` within the beforeValidate field hook was not correctly falling
back to the document value when no value was passed inside the request
for the field.

### Why?
The fallback logic was running after the beforeValidate field hooks are
called.

### How?
Run the fallback logic before running the beforeValidate field hooks.

Fixes https://github.com/payloadcms/payload/issues/10923
2025-02-27 16:00:27 -05:00
Jacob Fletcher
c4bc0ae48a fix(next): disables active nav item (#11434)
When visiting a collection's list view, the nav item corresponding to
that collection correctly appears in an active state, but is still
rendered as an anchor tag. This makes it possible to reload the current
page by simply clicking the link, which is a problem because this
performs an unnecessary server roundtrip. This is especially apparent
when search params exist in the current URL, as the href on the link
does not.

Unrelated: also cleans up leftover code that was missed in this PR:
#11155
2025-02-27 15:21:28 -05:00
Patrik
f7b1cd9d63 fix(ui): duplicate basePath in Logout Button Link (#11432)
This PR resolves an issue where the `href` for the Logout button in the
admin panel included duplicate `basePath` values when `basePath` was set
in `next.config.js`.

The Logout button was recently updated to use `NextLink` (`next/link`),
which automatically applies the `basePath` from the Next.js
configuration. As a result, manually adding the `basePath` to the `href`
is no longer necessary.

Relevant PRs that modified this behavior originally: 
- #9275
- #11155
2025-02-27 13:58:27 -05:00
Jarrod Flesch
9c25e7b68e fix(plugin-multi-tenant): scope access constraint to admin collection (#11430)
### What?
The idea of this plugin is to only add constraints when a user is
present on a request. This change makes it so access control only
applies to admin panel users as they are the ones assigned to tenants.

This change allows you to more freely write access functions on tenant
enabled collections. Say you have 2 auth enabled collections, the plugin
would incorrectly assume since there is a user on the req that it needs
to apply tenant constraints. When really, you should be able to just add
in your own access check for `req.user.collection` and return true/false
if you want to prevent/allow other auth enabled collections for certain
operations.

```ts
import { Access } from 'payload'

const readByTenant: Access = ({ req }) => {
  const { user } = req
  if (!user || user.collection === 'auth2') return false
  return true
}
```

When you have a function like this that returns `true` and the
collection is multi-tenant enabled - the plugin injects constraints
ensuring the user on the request is assigned to the tenant on the doc
being accessed.

Before this change, you would need to opt out of access control with
`useTenantAccess` and then wire up your own access function:

```ts
import type { Access } from 'payload'
import { getTenantAccess } from '@payloadcms/plugin-multi-tenant/utilities'

export const tenantAccess: Access = async ({ req: { user } }) => {
  if (user) {
    if (user.collection === 'auth2') {
      return true
    }

    // Before, you would need to re-implement
    // internal multi-tenant access constraints
    if (user.roles?.includes('super-admin')) return true

    return getTenantAccess({
      fieldName: 'tenant',
      user,
    })
  }

  return false
}
```

After this change you would not need to opt out of `useTenantAccess` and
can just write:

```ts
import type { Access } from 'payload'
import { getTenantAccess } from '@payloadcms/plugin-multi-tenant/utilities'

export const tenantAccess: Access = async ({ req: { user } }) => {
  return Boolean(user)
}
```

This is because internally the plugin will only add the tenant
constraint when the access function returns true/Where _AND_ the user
belongs to the admin panel users collection.
2025-02-27 13:53:45 -05:00
Elliot DeNolf
1d252cbacf templates: bump for v3.25.0 (#11431)
🤖 Automated bump of templates for v3.25.0

Triggered by user: @denolfe

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-27 13:13:47 -05:00
Alessio Gravili
7118b6418f fix(ui): disable publish button if form is autosaving (#11343)
Fixes https://github.com/payloadcms/payload/issues/6648

This PR introduces a new `useFormBackgroundProcessing` hook and a corresponding `setBackgroundProcessing` function in the `useForm` hook.

Unlike `useFormProcessing` / `setProcessing`, which mark the entire form as read-only, this new approach only disables the Publish button during autosaving, keeping form fields editable for a better user experience.

I named it `backgroundProcessing` because it should run behind the scenes without disrupting the user. You could argue that it is a bit more generic than something like `isAutosaving`, but it signals intent: Background = do not disrupt the user.
2025-02-27 10:28:08 -07:00
Elliot DeNolf
bdf0113b2f chore(release): v3.25.0 [skip ci] 2025-02-27 12:06:03 -05:00
Sasha
3436fb16ea feat: allow to count related docs for join fields (#11395)
### What?
For the join field query adds ability to specify `count: true`, example:
```ts
const result = await payload.find({
  joins: {
    'group.relatedPosts': {
      sort: '-title',
      count: true,
    },
  },
  collection: "categories",
})

result.group?.relatedPosts?.totalDocs // available
```

### Why?
Can be useful to implement full pagination / show total related
documents count in the UI.

### How?
Implements the logic in database adapters. In MongoDB it's additional
`$lookup` that has `$count` in the pipeline. In SQL, it's additional
subquery with `COUNT(*)`. Preserves the current behavior by default,
since counting introduces overhead.


Additionally, fixes a typescript generation error for join fields.
Before, `docs` and `hasNextPage` were marked as nullable, which is not
true, these fields cannot be `null`.
Additionally, fixes threading of `joinQuery` in
`transform/read/traverseFields` for group / tab fields recursive calls.
2025-02-27 16:05:48 +00:00
Patrik
bcc68572bf docs: adds Reserved Field Names section to migration guide (#11308)
Added a new Reserved Field Names section to the migration guide.

Clarified that certain field names (`__v`, `salt`, `hash`, `file`, etc.)
are reserved for internal use and will be sanitized from the config if
used.

Included additional reserved names specific to `MongoDB`, `auth`-enabled
collections, and `upload`-enabled collections.

Added a note recommending against using field names with an underscore
(`_`) prefix, as they are reserved for internal columns and may cause
conflicts in `SQL` and other contexts.

Fixes #11159
2025-02-27 10:36:34 -05:00
Jacob Fletcher
6aa9da73f8 Revert "feat: simplify column prefs (#11390)" (#11427)
This reverts commit 69c0d09 in #11390.

In order to future proof column prefs, it probably is best to continue
to use the current shape. This change was intended to ensure that as
little transformation to URL params was made as possible for #11387, but
we will likely transform them after all.

This will ensure that we can add support for additional properties over
time, as needed. For example, if we hypothetically wanted to add a
custom `label` or similar feature to columns prefs, it would make more
sense to use explicit properties to identity `accessor` and `active`.

For example:

```ts
[
  {
    accessor: "title",
    active: true,
    label: 'Custom Label' // hypothetical
  }
]
```
2025-02-27 08:39:24 -05:00
Alessio Gravili
2a3682ff68 fix(deps): ensure Next.js 15.2.0 compatibility, upgrade nextjs and @types/react versions in monorepo (#11419)
This bumps next.js to 15.2.0 in our monorepo, as well as all @types/react and @types/react-dom versions. Additionally, it removes the obsolete `peerDependencies` property from our root package.json.

This PR also fixes 2 bugs introduced by Next.js 15.2.0. This highlights why running our test suite against the latest Next.js, to make sure Payload is compatible, version is important.

## 1. handleWhereChange running endlessly

Upgrading to Next.js 15.2.0 caused `handleWhereChange` to be continuously called by a `useEffect` when the list view filters were opened, leading to a React error - I did not investigate why upgrading the Next.js version caused that, but this PR fixes it by making use of the more predictable `useEffectEvent`.

## 2. Custom Block and Array label React key errors

Upgrading to Next.js 15.2.0 caused react key errors when rendering custom block and array row labels on the server. This has been fixed by rendering those with a key

## 3. Table React key errors

When rendering a `Table`, a React key error is thrown since Next.js 15.2.0
2025-02-27 05:56:09 +00:00
Jarrod Flesch
958e195017 feat(plugin-multi-tenant): allow customization of selector label (#11418)
### What?
Allows for custom labeling of the tenant selector shown in the sidebar.

Fixes https://github.com/payloadcms/payload/issues/11262
2025-02-26 22:39:51 -05:00
Jarrod Flesch
45cee23add feat(plugin-multi-tenant): filter users list and tenants lists (#11417)
### What?
- Adds `users` base list filtering when tenant is selected
- Adds `tenants` base list filtering when tenant is selected
2025-02-26 21:50:36 -05:00
Alessio Gravili
67b7a730ba docs: improve lexical code block documentation (#11416)
The existing code example had type errors when `strict: true` was enabled
2025-02-27 00:47:36 +00:00
Alessio Gravili
88a2841500 docs: add lexical docs for configuring jsx converters for internal links and overriding them (#11415)
This PR improves existing JSX converter docs and adds 2 new sections:
- **converting internal links** - addresses why a `"found internal link, but internalDocToHref is not provided"` error is thrown, and how to get around it
- **Overriding default JSX Converters**
2025-02-27 00:41:41 +00:00
Alessio Gravili
7e713a454a feat(richtext-lexical): allows client features to access components added to the import map by the server feature (#11414)
Lexical server features are able to add components to the import map through the `componentImports` property. As of now, the client feature did not have access to those. This is usually not necessary, as those import map entries are used internally to render custom components server-side, e.g. when a request to the form state endpoint is made.

However, in some cases, these import map entries need to be accessed by the client feature (see "Why" section below).

This PR ensures that keyed `componentImports` entries are made available to the client feature via the new `featureClientImportMap` property.

## Why?

This is a prerequisite of the lexical [wrapper blocks PR](https://github.com/payloadcms/payload/pull/9289), where wrapper block custom components need to be made to the ClientFeature. The ClientFeature is where the wrapper block node is registered - in order to generate the wrapper block node, we need access to the component
2025-02-27 00:21:15 +00:00
Jacob Fletcher
b975858e76 test: removes all unnecessary page.waitForURL methods (#11412)
Removes all unnecessary `page.waitForURL` methods within e2e tests.
These are unneeded when following a `page.goto` call because the
subsequent page load is already being awaited.

It is only a requirement when:

- Clicking a link and expecting navigation
- Expecting a redirect after a route change
- Waiting for a change in search params
2025-02-26 16:54:39 -05:00
Sasha
b540da53ec feat(storage-*): large file uploads on Vercel (#11382)
Currently, usage of Payload on Vercel has a limitation - uploads are
limited by 4.5MB file size.
This PR allows you to pass `clientUploads: true` to all existing storage
adapters
* Storage S3
* Vercel Blob
* Google Cloud Storage
* Uploadthing
* Azure Blob

And then, Payload will do uploads on the client instead. With the S3
Adapter it uses signed URLs and with Vercel Blob it does this -
https://vercel.com/guides/how-to-bypass-vercel-body-size-limit-serverless-functions#step-2:-create-a-client-upload-route.
Note that it doesn't mean that anyone can now upload files to your
storage, it still does auth checks and you can customize that with
`clientUploads.access`


https://github.com/user-attachments/assets/5083c76c-8f5a-43dc-a88c-9ddc4527d91c

Implements https://github.com/payloadcms/payload/discussions/7569
feature request.
2025-02-26 21:59:34 +02:00
Alessio Gravili
c6ab312286 chore: cleanup queues test suite (#11410)
This PR extracts each workflow of our queues test suite into its own file
2025-02-26 19:43:31 +00:00
Sasha
526e535763 fix: ensure custom IDs are returned to the result when select query exists (#11400)
Previously, behavior with custom IDs and `select` query was incorrect.
By default, the `id` field is guaranteed to be selected, even if it
doesn't exist in the `select` query, this wasn't true for custom IDs.
2025-02-26 17:05:50 +02:00
Alessio Gravili
e4712a822b perf(drizzle): use faster, direct db query for getting id to update in updateOne (#11391)
Previously, `updateOne` was using `buildFindManyArgs` and `findFirst` just to retrieve the ID of the document to update, which is a huge function that's not necessary to run just to get the document ID.

This PR refactors it to use a simple `db.select` query to retrieve the ID
2025-02-25 21:54:38 -07:00
Patrik
81fd42ef69 fix(ui): skip bulk upload thumbnail generation on non-image files (#11378)
This PR fixes an issue where bulk upload attempts to generate thumbnails
for non-image files, causing errors on the page.

The fix ensures that thumbnail generation is skipped for non-image
files, preventing unnecessary errors.

Fixes #10428
2025-02-25 16:55:44 -05:00
Sasha
6b6c289d79 fix(db-mongodb): hasNextPage with polymorphic joins (#11394)
Previously, `hasNextPage` was working incorrectly with polymorphic joins
(that have an array of `collection`) in MongoDB.

This PR fixes it and adds extra assertions to the polymorphic joins
test.

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-02-25 21:22:47 +00:00
Jacob Fletcher
69c0d09437 feat: simplify column prefs (#11390)
Transforms how column prefs are stored in the database. This change
reduces the complexity of the `columns` property by removing the
unnecessary `accessor` and `active` keys.

This change is necessary in order to [maintain column state in the
URL](https://github.com/payloadcms/payload/pull/11387), where the state
itself needs to be as concise as possible. Does so in a non-breaking
way, where the old column shape is transformed as needed.

Here's an example:

Before:

```ts
[
  {
    accessor: "title",
    active: true
  }
]
```

After:

```ts
[
  {
    title: true
  }
]
```
2025-02-25 15:18:14 -05:00
Elliot DeNolf
48f183bd42 ci: remove codeowners file (#11385)
Since codeowner approvals are not currently required, the codeowners
file is only serving to add reviewers to PRs.

Removing the codeowners file for now as this is not desired. Can be
re-introduced at a later date if required approvers are implemented.
2025-02-25 10:06:14 -05:00
Jarrod Flesch
36e152d69d fix(ui): allow json fields to be updated externally (#11371)
### What?
Unable to update json fields externally. For example, calling `setValue`
on a json field would not be reflected in the admin panel UI.

### Why?
JSON fields use the monaco editor to manage state internally, so
programmatically updating the value in state does not change the
internal value.

### How?
Set a ref when the user updates the value and then unset the ref after
the change is complete.

Inside the hook that watches `value`, if the value changed and the
change came from the system (i.e. a programmatic change) refresh the
editor by adjusting its key prop. If the change was made by the user,
there is no need to refresh the editor.

Fixes https://github.com/payloadcms/payload/issues/10819
2025-02-25 09:44:06 -05:00
Kendell Joseph
1e698c2bdf chore(plugin-cloud): refresh session on ExpiredToken error code (#8904)
Fixes https://github.com/payloadcms/payload/issues/8404

This code will refresh the session token upon receiving an
`ExpiredToken` error when `storageClient.getObject()` receives that
error.

- The `Error Code` detected is determined by the error reported in the
issue, and is an actual code from [AWS
documentation](https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html)
for Error Responses.

- If the request fails again, the error is thrown.
2025-02-25 09:17:30 -05:00
Patrik
7bb1c9d3c6 fix(ui): bulk upload DiscardWithoutSaving modal styles (#11381)
This PR fixes an issue where the `DiscardWithoutSaving` modal in the
bulk upload process was missing its styles.

The modal now displays correctly with the intended styling.

### Before:
![Screenshot 2025-02-24 at 8 20
52 PM](https://github.com/user-attachments/assets/c83a3119-28ce-4701-bc64-1219adeb2505)

### After:
![Screenshot 2025-02-24 at 8 20
07 PM](https://github.com/user-attachments/assets/62d364c2-b64c-4bd9-bf05-7481d609c6e4)

Fixes #11380
2025-02-25 08:56:42 -05:00
Alessio Gravili
4410a49132 refactor: simplify collection, global and auth operations (#11374)
Continuation of https://github.com/payloadcms/payload/pull/11372 but for our collection, global and auth operations

Previously, we were quite frequently using `.reduce()` to run hooks. This PR replaces them with simple `for` loops, which is less overhead, less code, less confusing and simpler to understand.
2025-02-24 20:50:25 +00:00
Alessio Gravili
820a6ec55d fix: ensure generated types for config.blocks are not undefined if no blocks defined (#11377)
Previously, if no `config.blocks` were defined, `blocks: undefined` would incorrectly be added to the generated types.
2025-02-24 20:33:22 +00:00
Jacob Fletcher
0a1af45549 fix(next): nested relationship filter options (#11375)
Continuation of #11008. When `filterOptions` are set on a relationship
field that is _nested within another field_, those filter options are
not applied to `Filter` component in the list view. This is because we
were only shallowly resolving filter options on top-level fields, as
opposed to recursively traversing fields to resolve them even when
deeply nested.
2025-02-24 15:24:25 -05:00
Patrik
09ca5143eb fix(plugin-nested-docs): fallback to empty string if useAsTitle field is undefined (#11338)
Updated `formatBreadcrumb` to fall back to an empty string if the
`useAsTitle` field for the document is undefined.

This handles cases where the field is optional or not filled out,
ensuring the label is never `undefined`.

Fixes #10377
2025-02-24 14:00:41 -05:00
Patrik
f1b005c4f5 fix(ui): object type field labels not displaying in search filter (#11366)
This PR ensures that when `titleField.label` is provided as an object,
it is correctly translated and displayed in the search filter's
placeholder.

Previously, the implementation only supported string values, which could
lead to issues with object type labels. With these changes, object type
labels will now properly show as intended in the search filter.

Fixes #11348
2025-02-24 13:38:21 -05:00
Alessio Gravili
dc9e8fa655 refactor: simplify running field hooks (#11372)
Previously, we were quite frequently using `.reduce()` to sequentially run field hooks. This PR replaces them with simple `for` loops, which is less overhead, less code, less confusing and simpler to understand.

Additionally, it refactors `mergeLocaleActions` which previously was unnecessarily complex. They no longer entail async code, thus we no longer have to juggle with promises
2025-02-24 18:37:33 +00:00
Jacob Fletcher
2477fc6c75 fix(ui): custom block labels stale when reordering blocks (#11367)
When blocks have custom row labels, those row labels become stale when
reordering blocks. After moving a block, for example, the row label will
jump back the original block until form state returns with the proper
rendering order. This is especially evident on slow networks.
2025-02-24 16:52:12 +00:00
Jarrod Flesch
37781808eb fix(plugin-multi-tenant): user access, thread field names through (#11365)
### What?
Two things:
1. Users unassigned to a tenant could not access their own account
2. Custom `tenantsArrayFieldName` and `tenantsArrayTenantFieldName`
configurations were not being used in all cases

### Why?
1. The access constraint provided by the plugin would not allow them to
make changes to their own account
2. `getUserTenantIDs` and `afterTenantDelete` were not using the custom
field names properly

### How?
1. Adds constraint for users allowing them to manage their own account
by default. Externally nothing has changed. If you need to lock your
users access control down you should do that just as you would without
this plugin.
2. Threads the field names through for usage.

Fixes https://github.com/payloadcms/payload/issues/11317
2025-02-24 11:17:09 -05:00
Jessica Chowdhury
d92c0009ed fix(plugin-nested-docs): corrects data shape of breadcrumbs returned in hooks (#10866)
### What?
The `plugin-nested-docs` returns an array of breadcrumbs - the
`resaveChildren` file accidentally processed the breadcrumbs twice, once
where the data is updated and once within the `populateBreadcrumbs`
function which was causing the objects to be double nested.

### How?
Removes the extra nesting from `resaveChildren` file and allows the
`populateBreadcrumbs` to return the final data.

Fixes #10855
2025-02-24 11:10:21 -05:00
Rafal Sypien
d32608649c docs: add info about changes in localized fields to v2 -> v3 migration guide (#11244)
### What?
Information that locale fields in database are changing to a simpler
data structure in v3.

### Why?
Simple data migration is not enough to get it working, I had to spend
quite some time to figure out migration files and still it required some
manual input. Maybe others will find it useful when starting a v3
migration.

### How?
I had to do some custom migration scripts on my own to get v3 to work
with existing pages data.

---------

Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
2025-02-24 17:36:11 +02:00
Paul
f9121c1a3a fix(ui): link element triggering clicks twice (#11362)
Fixes
https://github.com/payloadcms/payload/issues/11359#issuecomment-2678213414

The link element by using startTransitionRoute and manually calling
router.push would technically cause links to be clicked twice, by not
preventing default browser behaviour.

This caused a problem on clicking /create links as it hit the route
twice. Added a test making sure Create new doesn't lead to abnormally
increased document counts

Changes:
- Added `e.preventDefault()` in our Link element
- Added `preventDefault` as an optional prop to this element so that
people can handle it on their own if needed via a custom `onClick`
2025-02-24 14:49:52 +00:00
Jarrod Flesch
f477e0e3c4 feat(plugin-multi-tenant): export useTenantSelection hook for public usage (#11364)
Exports the `useTenantSelection` hook from the multi-tenant plugin, this
way other users can import and use the hook along with it's methods.

Can be imported:
```ts
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
```

The context returned:

```ts
type ContextType = {
  /**
   * Array of options to select from
   */
  options: OptionObject[]
  /**
   * The currently selected tenant ID
   */
  selectedTenantID: number | string | undefined
  /**
   * Prevents a refresh when the tenant is changed
   *
   * If not switching tenants while viewing a "global", set to true
   */
  setPreventRefreshOnChange: React.Dispatch<React.SetStateAction<boolean>>
  /**
   * Sets the selected tenant ID
   * 
   * @param args.id - The ID of the tenant to select
   * @param args.refresh - Whether to refresh the page after changing the tenant
   */
  setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void
}
```
2025-02-24 09:29:45 -05:00
Sasha
4224c68002 docs: fix links to react hooks (#11344) 2025-02-22 13:23:00 +00:00
Paul
b014416584 feat: add support for not_like operation (#11326)
This PR adds support for `not_like` as a query operation. Functions just
as a negative to `like`, uses `ilike` in postgres and `like` in sqlite
2025-02-22 00:32:37 +00:00
Alessio Gravili
1725af5e3a fix(next): use correct hmr url if assetPrefix is set in next config (#10859)
The next.js assetPrefix needs to be included in the websocket URL.

Previously, we were appending both assetPrefix and basePath, which is incorrect. `assetPrefix` overrides `basePath` if both are set. This PR mimics the way Next.js connects to the HMR server.

Sources:
- https://github.com/AlessioGr/next.js/blob/canary/packages/next/src/server/lib/router-server.ts#L688
- https://github.com/AlessioGr/next.js/blob/canary/packages/next/src/server/config.ts#L322
- https://github.com/AlessioGr/next.js/blob/canary/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx
2025-02-22 00:27:07 +00:00
Sasha
a13d4fe5c6 perf: remove deep copy of the entire sanitized entity config in configToJSONSchema (#11342)
Small performance improvement for types generation and anywhere else
`configToJSONSchema` can be used.

We did a deep copy of the whole sanitized entity config, which can be
expensive. Now, to do the needed mutation of `flattenedFields`, we just
create a new reference of `flattenedFields` for that.
2025-02-22 01:59:50 +02:00
Alessio Gravili
3ffc268438 fix: safely access config.blocks during type generation (#11320)
A discord user reported a "`TypeError: config.blocks is not iterable`" error when generating types: https://discord.com/channels/967097582721572934/1342383112289517578/1342383112289517578

To prevent that error, this PR ensures config.blocks is safely accessed during type generation
2025-02-21 23:26:28 +00:00
Jacob Fletcher
d766b1904c feat(ui): threads row data through list drawer onSelect callback (#11339)
When rendering a list drawer, you can pass a custom `onSelect` callback
to execute when the user clicks on the linked cell within the table. The
underlying handler, however, only passes the `docID` and
`collectionSlug` args through the callback, rather than the document
itself. This makes it impossible to perform side-effects that require
the data of the row that was selected.

Instances of this callback were also largely untyped.

Needed for #11330.
2025-02-21 17:08:05 -05:00
Jacob Fletcher
f31568c69c refactor(ui): moves bulk edit controls (#11332)
Bulk edit controls are currently displayed within the search bar of the
list view. This doesn't make sense from a UX perspective, as the current
selection is displayed somewhere else entirely. These controls also take
up a lot of visual real estate which is beginning to get overused
especially after the introduction of "list menu items" in #11230, and
the potential introduction of "saved filters" controls in #11330.

Now, they are rendered contextually _alongside_ the selection count. To
make room for these new controls, they are displayed in plain text and
the entity labels have been removed from the selection count.
2025-02-21 15:06:30 -05:00
Jacob Fletcher
a8bec9a1b2 fix(ui): only show bulk select all when count is less than total (#11329)
It doesn't make sense to display "select all" when bulk editing if your
current selection already contains all records.
2025-02-21 13:42:47 -05:00
Patrik
5d7be15c52 templates: update docker-compose to use postgres by default in postgres specific templates (#11328)
### What?

Updated `docker-compose.yml` to use `Postgres` by default, with
`MongoDB` commented out in the templates that are by default using the
postgres adapter.

Fixes #11322
2025-02-21 13:15:54 -05:00
Alessio Gravili
0058f82d87 perf: add limit: 1 and pagination: false to various payload queries (#11319)
`payload.find` queries can be made faster by specifying `limit: 1` and `pagination: false` when only the first document is needed. This PR applies those options to various queries to improve performance.
2025-02-21 11:09:40 -07:00
Tib
6ff380ce59 docs: update outdated docs/rich-text/overview feature names (#11324)
Features was outdated
2025-02-21 10:29:57 -07:00
Sasha
f779e48a58 fix: working bin configuration for custom scripts (#11294)
Previously, the `bin` configuration wasn't working at all.
Possibly because in an ESM environment this cannot work, because
`import` always returns an object with a default export under the
`module` key.
```ts
const script: BinScript = await import(pathToFileURL(userBinScript.scriptPath).toString())
await script(config)
```
Now, this works, but you must define a `script` export from your file.
Attached an integration test that asserts that it actually works. Added
documentation on how to use it, as previously it was missing.
This can be also helpful for plugins.

### Documentation

Using the `bin` configuration property, you can inject your own scripts
to `npx payload`.
Example for `pnpm payload seed`:

Step 1: create `seed.ts` file in the same folder with
`payload.config.ts` with:

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

import payload from 'payload'

// Script must define a "script" function export that accepts the sanitized config
export const script = async (config: SanitizedConfig) => {
  await payload.init({ config })
  await payload.create({ collection: 'pages', data: { title: 'my title' } })
  payload.logger.info('Succesffully seeded!')
  process.exit(0)
}
```

Step 2: add the `seed` script to `bin`:
```ts
export default buildConfig({
  bin: [
    {
      scriptPath: path.resolve(dirname, 'seed.ts'),
      key: 'seed',
    },
  ],
})
```

Now you can run the script using:
```sh
pnpm payload seed
```
2025-02-21 18:15:27 +02:00
Sasha
1dc748d341 perf(db-mongodb): remove JSON.parse(JSON.stringify) copying of results (#11293)
Improves performance and optimizes memory usage for mongodb adapter by
cutting down copying of results via `JSON.parse(JSON.stringify())`.
Instead, `transform` does necessary transformations (`ObjectID` ->
`string,` `Date` -> `string`) without any copying
2025-02-21 17:31:24 +02:00
Alessio Gravili
c7c5018675 perf(next): reduce getNavPrefs calls from 3 to 1 per page load (#11318)
Previously, we were calling `getNavPrefs` (a payload.find call) three times for every single page load.

This PR:

1. Ensures that `getNavPrefs` is called only once per page load, reducing two unnecessary `payload.find` calls every time a page is loaded or navigated to.
2. Adds `pagination: false` to the `payload.find` call, making it more efficient and improving performance.

## How?

We were using React's cache to ensure that navigation preferences (`getNavPrefs`) were fetched only once per request. However, this wasn't working as expected because the first argument of `getNavPrefs` was an object. Each time it was called, a new object reference was passed, preventing React from caching it properly.

To fix this, this PR ensures that only primitive values are used as arguments for caching, following best practices and making the cache function work as intended.
2025-02-21 05:17:39 +00:00
Alessio Gravili
9728d80592 fix: db transaction errors caused by checkDocumentLockStatus (#11287)
Just like https://github.com/payloadcms/payload/pull/11269, we stop passing through `req` to db operations in `checkDocumentLockStatus`.

After extensive testing, this seems to get rid of all transaction errors that occurred when I was testing autosave against a remote mongo DB.

We keep the `req` for postgres, as it mysteriously breaks in CI - this cannot be reproduced locally
2025-02-20 20:01:15 -07:00
Sasha
0594701004 chore: add JSDoc for globals Local API operations (#11313)
Continuation of https://github.com/payloadcms/payload/pull/11265 for
globals.
Documents all the possible properties of globals Local API operations
with JSDoc.
2025-02-21 04:33:36 +02:00
Alessio Gravili
845c647ebc perf: ensure fetching and updating preferences doesn't cause transaction errors and is done correctly (#11311)
## getPreferences function caching

Our `getPreferences` function used in the ui package is now wrapped in react cache, to minimize the amount of times it runs on a single request. This mimics the behavior of our other `getPreferences` function in the next package.

## getPreferences  incorrect behavior

The `getPreferences` function in the next package was passing through the incorrect user slug. This would not have been noticeable in projects with just one users collection, but might break in projects with multiple users collections.

## getPreferences performance optimization

This PR adds `pagination: false` to the getPreferences payload.find() call, which will speed up the query.

## upsertPreferences transaction errors

Due to the potential of preference upsert operations running in parallel (e.g. when switching locales), this PR disables transactions in the preferences creation / update calls. This fixes the transaction errors reported in https://github.com/payloadcms/payload/issues/11310
2025-02-21 01:53:56 +00:00
Sasha
f6f6a1dc99 chore: cut down logging noise when file was not found on the disk (#11295)
When `/uploads/file/file-path.jpg` endpoint is requested, and
`file-path.jpg` _was found_ in the database, but was not found on the
disk, the error like this is printed to the console:

```
[03:34:54] ERROR: ENOENT: no such file or directory, stat '/Users/sasha/work/payload/test/uploads/collections/Upload1/uploads/Screenshot 2025-02-19 at 18.50.46.png'
    err: {
      "type": "Error",
      "message": "ENOENT: no such file or directory, stat '/Users/sasha/work/payload/test/uploads/collections/Upload1/uploads/Screenshot 2025-02-19 at 18.50.46.png'",
      "stack":
          Error: ENOENT: no such file or directory, stat '/Users/sasha/work/payload/test/uploads/collections/Upload1/uploads/Screenshot 2025-02-19 at 18.50.46.png'
              at async Object.stat (node:internal/fs/promises:1037:18)
              at async getFileHandler (webpack-internal:///(rsc)/./packages/payload/src/uploads/endpoints/getFile.ts:59:19)
              at async handleEndpoints (webpack-internal:///(rsc)/./packages/payload/src/utilities/handleEndpoints.ts:178:26)
              at async eval (webpack-internal:///(rsc)/./packages/next/src/routes/rest/index.ts:27:26)
              at async AppRouteRouteModule.do (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/compiled/next-server/app-route.runtime.dev.js:10:32847)
              at async AppRouteRouteModule.handle (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/compiled/next-server/app-route.runtime.dev.js:10:39868)
              at async doRender (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/base-server.js:1452:42)
              at async responseGenerator (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/base-server.js:1822:28)
              at async DevServer.renderToResponseWithComponentsImpl (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/base-server.js:1832:28)
              at async DevServer.renderPageComponent (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/base-server.js:2259:24)
              at async DevServer.renderToResponseImpl (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/base-server.js:2297:32)
              at async DevServer.pipeImpl (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/base-server.js:959:25)
              at async NextNodeServer.handleCatchallRenderRequest (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/next-server.js:281:17)
              at async DevServer.handleRequestImpl (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/base-server.js:853:17)
              at async /Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/dev/next-dev-server.js:371:20
              at async Span.traceAsyncFn (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/trace/trace.js:153:20)
              at async DevServer.handleRequest (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/dev/next-dev-server.js:368:24)
              at async invokeRender (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/lib/router-server.js:230:21)
              at async handleRequest (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/lib/router-server.js:408:24)
              at async NextCustomServer.requestHandlerImpl (/Users/sasha/work/payload/node_modules/.pnpm/next@15.1.5_@opentelemetry+api@1.9.0_@playwright+test@1.50.0_babel-plugin-macros@3.1.0_babel-_vtyh6lbrnb3jbtpvhe5wg2qgzq/node_modules/next/dist/server/lib/router-server.js:432:13)
              at async Server.<anonymous> (file:///Users/sasha/work/payload/test/dev.ts:1:2848)
      "errno": -2,
      "code": "ENOENT",
      "syscall": "stat",
      "path": "/Users/sasha/work/payload/test/uploads/collections/Upload1/uploads/Screenshot 2025-02-19 at 18.50.46.png"
    }
```
Which is quite noisy as we understand why this error happens (and it
might be intentional when you load production DB to local and don't have
any files)

Now, the logging here in case of `ENOENT`  is simplified to this:
```
[03:43:35] ERROR: File Screenshot 2025-02-19 at 18.50.46.png for collection uploads-1 is missing on the disk. Expected path: /Users/sasha/work/payload/test/uploads/collections/Upload1/uploads/Screenshot 2025-02-19 at 18.50.46.png
```


Fixes https://github.com/payloadcms/payload/issues/6246
2025-02-21 02:05:10 +02:00
Sasha
ee5e96a965 chore: add JSDoc for collection Local API operations properties (#11265)
### What?
Adds JSDoc for options of all collection Local API operations
_Every_ property now is documented there, even those that aren't on our
website docs.

### Why?
This is useful to have, now you can hover over any property to see what
it does in your editor.
Some properties also link to the documentation website directly for more
info.

### How?
Updates every collection operation arguments definition with JSDoc.

For globals will be a separate PR.
2025-02-21 02:04:52 +02:00
Said Akhrarov
22f61ad79e feat(ui): adds support for block groups (#11239)
<!--

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 introduces support for the `admin.group` property in block
configs. This property enables blocks to be grouped under a common,
potentially localized, label in the block drawer component. This makes
it easier to sort through large collections of blocks. Previously, all
blocks would be in one common layout.

This PR also encompasses documentation changes and e2e tests to check
for the rendering of group labels.

### Why?
To make it easier to organize many blocks in block fields.

### How?
By introducing a new `admin.group` property in block configs and
assembling them in the blocks drawer component.

Before:

![Editing-Block-Field-Payload--before](https://github.com/user-attachments/assets/fb0c887b-ee47-46a1-a249-c4a4b7a5c13c)

After:

![Editing-Block-Field-Payload--after](https://github.com/user-attachments/assets/046d5a6f-3108-4464-ac69-8b7afcf27094)

Demo:

[Editing---Block-Field---Payload-groups-demo.webm](https://github.com/user-attachments/assets/2b351dc1-0d14-4a5b-ae71-bcd31fbb23df)

Addresses #5609
2025-02-20 19:51:47 +00:00
Jarrod Flesch
460d50baa3 fix(ui): confirmation modal should build off of drawerDepth, instead … (#11305)
When the new ConfirmationModal is used outside of the context of an
EditDepthProvider, it stacks behind currently open modals. This is
apparent when using it in custom views.

The fix is to build the confirm modal's depth off of drawerDepth instead
of editDepth.
2025-02-20 14:40:29 -05:00
Alessio Gravili
76bd05cc5d perf(next): avoid unnecessary upsertPreferences call on page load (#11302)
`getRequestLocale` => `upsertPreferences` is already called as part of `initReq`, yet we were still unnecessarily calling `getRequestLocale` afterwards, which potentially resulted in at least one unnecessary `payload.find()` or `payload.update()` call.
2025-02-20 19:00:31 +00:00
Boyan Bratvanov
af92c1562c docs: fix formatting in field hooks table (#11300)
The line for `siblingFields` has an extra newline and space that's
breaking the table formatting.

https://payloadcms.com/docs/hooks/fields
2025-02-20 09:19:46 -05:00
Jessica Chowdhury
6fad5d7c0a chore(translations): adds missing client keys and removes translated validation errors (#10841)
### What?
Fixes translation errors that are thrown when JSON field validation
outputs an error.

### How?
Removes translation function `t()` from wrapping the errors and adds
translation keys that were missing from `clientKeys.ts`.

Fixes #10543
2025-02-20 12:49:15 +00:00
Jessica Chowdhury
c05f10abbc chore: passes allowCreate into list drawer and adds test (#11284)
### What?
We had an `allowCreate` prop for the list drawer that doesn't do
anything. This PR passes the prop through so it can be used.

### How?
Passes `allowCreate` down to the list view and ties it with
`hasCreatePermission`

#### Testing
- Use `admin` test suite and `withListDrawer` collection.
- Test added to the `admin/e2e/list-view`.

Fixes #11246
2025-02-20 12:31:36 +00:00
Jessica Chowdhury
26163a7535 fix(next): uses assetPrefix from next config in webpack-hmr URL (#11229)
### What?
Adding `assetPrefix` to the `next.config` prevents the hot module
reloading functionality.

### Why & How?
Need to incorporate `assetPrefix` into the URL generated for webpack
HMR.

Fixes #11150

#### Testing
1. Add `assetPrefix: '/test'` to the `next.config.mjs` in the root
folder
2. Run `pnpm test _community`
3. Go to the `_community/collections/posts` config and change a field
4. Open post collection in browser and see no change (if this PR is
checked out then you _**will**_ see the change)
2025-02-20 12:30:44 +00:00
Patrik
c517e7e688 docs: removes outdated rateLimit option (#11291)
### What?

This PR removes references to the `rateLimit` option from the
documentation, as it was deprecated in Payload v3.

Since Payload now runs on Next.js, which are often deployed
serverlessly, built-in rate limiting is no longer supported.

Users are encouraged to implement rate limiting at the load balancer,
proxy level, or use services like Cloudflare.

Fixes #10321
2025-02-19 16:12:04 -05:00
Alessio Gravili
563c21bec0 feat(richtext-lexical): support single-quoted jsx property values in mdx converter (#11290)
The following MDX:

```tsx
<Banner type='info'>
  Hello
</Banner>
```

was not able to be parsed by the lexical mdx converter, as the jsx props string extractor did not support the single quotes around the `info` string.
2025-02-19 21:11:50 +00:00
Alessio Gravili
b1e9aa53ab docs: fix invalid jsx in banner block (#11289)
Single quote strings are not supported by our jsx parser
2025-02-19 13:41:22 -07:00
Alessio Gravili
26127567b6 fix(richtext-lexical): incorrect UploadData types (#11288)
This PR fixes the `UploadData` type that was weakened in a previous PR, causing a breaking change. It also improves the newly added `UploadDataImproved` type by bringing back its support for generated types and using the `UploadCollectionSlug` type helper to restrict collection slugs to upload-enabled collections.
2025-02-19 20:22:44 +00:00
Elliot DeNolf
f3161f9405 chore(release): v3.24.0 [skip ci] 2025-02-19 13:37:26 -05:00
Paul
e83318b156 fix(ui): minor issues with tabs and publish buttons when in RTL (#11282)
Fixes https://github.com/payloadcms/payload/issues/11162

Our tabs had wrong spacing in RTL.

The publish button with its dropdown had its borders and border radiuses
on the wrong side for RTL and fixed a minor issue in website template
around RTL margins.

Now publish button looks as expected in RTL:

![image](https://github.com/user-attachments/assets/023d27c9-dc14-4aa1-a5e7-b48f498921fd)
2025-02-19 17:18:01 +00:00
Sasha
009e9085fc fix(ui): turbopack with the latest next.js canary [skip lint] (#11280)
Fixes https://github.com/payloadcms/payload/issues/11211

Disables prepending `"use client"` to `.map` files
2025-02-19 18:55:04 +02:00
Dan Ribbens
9fc1cd0d24 fix(ui): disabledLocalStrategy.enableFields missing email/username fields (#11232)
When using `disabledLocalStrategy.enableFields`, it was impossible to
create a user from the admin panel because the username or email field
was missing.

![Screenshot 2025-02-17
133851](https://github.com/user-attachments/assets/f84ac74e-a3ce-4428-81b5-7135fc1cb917)

---------

Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
2025-02-19 11:43:40 -05:00
Jarrod Flesch
0651ae0727 fix: versions not loading properly (#11256)
### What?
The admin panel was not respecting where constraints returned from the
readAccess function.

### Why?
`getEntityPolicies` was always using `find` when looping over the
operations, but `readVersions` should be using `findVersions`.

### How?
When the operation is `readVersions` run the `findVersions` operation.

Fixes https://github.com/payloadcms/payload/issues/11240
2025-02-19 10:22:31 -05:00
Dan Ribbens
618624e110 fix(ui): unsaved changes allows for scheduled publish missing changes (#11001)
### What?
When you first edit a document and then open the Schedule publish
drawer, you can schedule publish changes but the current changes made to
the form won't be included.

### Why?
The UX does not make it clear that the changes you have in the form are
not actually going to be published.

### How?
Instead of allowing that we just disable the Schedule Publish drawer
toggler so that users are forced to save a draft first.

In addition to the above, this change also passes a defaultType so that
an already published document will default the radio type have
"Unpublish" selected.
2025-02-19 10:10:29 -05:00
felismargarita
cd48904798 fix(next): imports toast from @payloadcms/ui (#11279)
Restoring a version has two types of messages, success and error, but no
matter if this action is a success or a failure, the toast message is
never displayed.

The fix is to import the toast from `@payloadcms/ui` instead of `sonner`
directly.

Fixes #11059
2025-02-19 09:49:00 -05:00
Paul
8b0ae902e7 fix(ui): timezone issue related to date only fields in Pacific timezones (#11203)
Fixes https://github.com/payloadcms/payload/issues/10962

This fix addresses fields with timezones enabled specifically for not
time pickers. If all you want to do is pick a date such as 14th Feb, it
would store the incorrect version and display a date in the future for
people in the Pacific.

This is because Auckland is +12 offset, but +13 with Daylight Savings
Time. In our date picker we try to normalise date pickers with no time
to 12pm and so half the year we ended up pushing dates visually to the
next day for people in the pacific only. Other regions were not affected
by this because their offset would be less than 12.

This PR fixes this by ensuring that our dates are always normalised to
selected timezone's 12pm date to UTC.

There's also additional tests for these two fields from 3 main locations
to cover a wider range of possible timezones.
2025-02-19 14:46:05 +00:00
Fredrik
80b33adf6b feat(ui): enable specific css selectors on the localizer per locale
Adds a `locale-${localeCode}` data attribute to the localizer label and buttons.
2025-02-19 14:41:01 +00:00
Alessio Gravili
9b8f8d70ca fix: db transaction errors caused by checkDocumentLockStatus (#11273)
Just like https://github.com/payloadcms/payload/pull/11269, we stop
passing through `req` to db operations in `checkDocumentLockStatus`.

After extensive testing, this seems to get rid of all transaction errors
that occurred when I was testing autosave against a remote mongo DB.
2025-02-19 14:18:53 +00:00
Jacob Fletcher
af5554981c refactor(ui): simplifies confirmation modal callback (#11278)
Removes unnecessary callback args from the `onConfirm` callback in the
new `ConfirmationModal` component. Now, the component will close and
reset `isConfirming` state for itself.
2025-02-19 09:18:37 -05:00
Germán Jabloñski
7024da8be3 docs: fix variable name typo in usePayloadAPI (error → isError) (#11249) 2025-02-19 08:52:53 -05:00
Paul
acead1083b feat: add support for interfaceName on radio and select fields to create reusable top level types (#11277)
Adds support for `interfaceName` on radio and select fields. Adding this
property will extract your provided options into a top level type for
re-use.


![image](https://github.com/user-attachments/assets/be5c3e17-5127-4546-a778-d3aa801dec90)

Added types test to make sure assignment is consistent.
2025-02-19 13:51:03 +00:00
Tib
bf103cc025 docs: fix typo in cors (#11266)
<!--

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-02-19 12:51:24 +00:00
Paul
8bbe7bcbbe feat(translations): add support for lithuanian (#11243)
Adds support for lithuanian language
2025-02-19 11:04:16 +00:00
Jacob Fletcher
bd8ced1b60 feat(ui): confirmation modal (#11271)
There are nearly a dozen independent implementations of the same modal
spread throughout the admin panel and various plugins. These modals are
used to confirm or cancel an action, such as deleting a document, bulk
publishing, etc. Each of these instances is nearly identical, leading to
unnecessary development efforts when creating them, inconsistent UI, and
duplicative stylesheets.

Everything is now standardized behind a new `ConfirmationModal`
component. This modal comes with a standard API that is flexible enough
to replace nearly every instance. This component has also been exported
for reuse.

Here is a basic example of how to use it:

```tsx
'use client'
import { ConfirmationModal, useModal } from '@payloadcms/ui'
import React, { Fragment } from 'react'

const modalSlug = 'my-confirmation-modal'

export function MyComponent() {
  const { openModal } = useModal()

  return (
    <Fragment>
      <button
        onClick={() => {
          openModal(modalSlug)
        }}
        type="button"
      >
        Do something
      </button>
      <ConfirmationModal
        heading="Are you sure?"
        body="Confirm or cancel before proceeding."
        modalSlug={modalSlug}
        onConfirm={({ closeConfirmationModal, setConfirming }) => {
          // do something
          setConfirming(false)
          closeConfirmationModal()
        }}
      />
    </Fragment>
  )
}
```
2025-02-19 02:27:03 -05:00
Alessio Gravili
132852290a fix(ui): database errors when running autosave and ensure autosave doesn't run unnecessarily (#11270)
## Change 1 - database errors when running autosave

The previous autosave implementation allowed multiple autosave fetch
calls (=> save document draft) to run in parallel. While the
AbortController aborted previous autosave calls if a new one comes in in
order to only process the latest one, this had one flaw:

Using the AbortController to abort the autosave call only aborted the
`fetch` call - it did not however abort the database operation that may
have started as part of this fetch call. If you then started a new
autosave call, this will start yet another database operation on the
backend, resulting in two database operations that would be running at
the same time.

This has caused a lot of transaction errors that were only noticeable
when connected to a slower, remote database. This PR removes the
AbortController and ensures that the previous autosave operation is
properly awaited before starting a new one, while still discarding
outdated autosave requests from the queue **that have not started yet**.

Additionally, it cleans up the AutoSave component to make it more
readable.

## Change 2 - ensure autosave doesn't run unnecessarily

If connected to a slower backend or database, one change in a document
may trigger two autosave operations instead of just one. This is how it
could happen:

1. Type something => formstate changes => autosave is triggered
2. 200ms later: form state request is triggered. Autosave is still
processing
3. 100ms later: form state comes back from server => local form state is
updated => another autosave is triggered
4. First autosave is aborted - this lead to a browser error. This PR
ensures that that error is no longer surfaced to the user
5. Another autosave is started

This PR adds additional checks to ONLY trigger an autosave if the form
DATA (not the entire form state itself) changes. Previously, it ran
every time the object reference of the form state changes. This includes
changes that do not affect the form data, like `field.valid`. =>
Basically every time form state comes back from the server, we were
triggering another, unnecessary autosave
2025-02-19 03:38:32 +00:00
Alessio Gravili
7f5aaad6a5 fix(ui): do not pass req in handleFormStateLocking (#11269)
Not passing through `req` ensures that the db operations in
`handleFormStateLocking` run independently, preventing them from being
part of the same transaction. Since locked document operations don't
really require transactional consistency, this change helps avoid
unnecessary transaction errors that have previously occurred here.
2025-02-19 02:43:20 +00:00
Sasha
38c1c113ca docs: remove outdated res parameter in login and resetPassword operations (#11268)
Fixes https://github.com/payloadcms/payload/issues/9829

As described in the issue, the `res` parameter does no more exist for
these operations. Additionally, marks `req` as an optional property.
2025-02-19 03:59:05 +02:00
Alessio Gravili
7922d66181 fix(db-mongodb): properly handle document notfound cases for update and delete operations (#11267)
In the `findOne` db operation, we return `null` if the document was not
found.

For single-document delete and update operations, if the document you
wanted to update is not found, the following runtime error is thrown
instead: `Cannot read properties of null (reading '_id')`.

This PR correctly handles these cases and returns `null` from the db
method, just like the `findOne` operation.
2025-02-19 01:19:59 +00:00
Sasha
0d7cf3fca2 docs: update join field docs (#11264)
### What?
Updates the join field documentation. 
Mentions:
* Now you can specify an array of `collection` -
https://github.com/payloadcms/payload/pull/10919
* Querying limitation for join fields, planned
https://github.com/payloadcms/payload/discussions/9683
* Querying limitation for joined documents when the join field has an
array of `collection` for fields inside arrays and blocks.

### Why?
To have up to date documentation for an array of `collection` and so
users can know about limitations.

### How?
Updates the file on path `docs/fields/join.mdx`.
2025-02-19 00:31:15 +02:00
Jacob Fletcher
8166784ba2 test: blocks field helpers (#11259)
Similar to the goals of #11026. Adds helper utilities to make
interacting with the blocks field easier within e2e tests. This will
also standardize common functionality across tests and reduce the
overall lines of code for each, making them easier to navigate and
digest.

The following helpers are now available:

- `openBlocksDrawer`: self-explanatory
- `addBlock`: opens the blocks drawer and selects the given block
- `reorderBlocks`: similar to `reorderColumn`, moves blocks using the
drag handle
- `removeAllBlocks`: iterates all rows of a given blocks field and
removes them
2025-02-18 15:48:57 -05:00
Germán Jabloñski
be52a2a223 Merge remote-tracking branch 'origin/main' into revert-11133-typescript-strict-plugin 2025-02-17 09:12:40 -03:00
Germán Jabloñski
0824b4f34c Revert "chore: add typescript-strict-plugin to the payload package for increm…"
This reverts commit 6eee787493.
2025-02-13 09:48:29 -03:00
741 changed files with 13042 additions and 6365 deletions

37
.github/CODEOWNERS vendored
View File

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

View File

@@ -4,7 +4,7 @@ Depending on the quality of reproduction steps, this issue may be closed if no r
### Why was this issue marked with the `invalid-reproduction` label?
To be able to investigate, we need access to a reproduction to identify what triggered the issue. We prefer a link to a public GitHub repository created with `create-payload-app@beta -t blank` or a forked/branched version of this repository with tests added (more info in the [reproduction-guide](https://github.com/payloadcms/payload/blob/main/.github/reproduction-guide.md)).
To be able to investigate, we need access to a reproduction to identify what triggered the issue. We prefer a link to a public GitHub repository created with `create-payload-app@latest -t blank` or a forked/branched version of this repository with tests added (more info in the [reproduction-guide](https://github.com/payloadcms/payload/blob/main/.github/reproduction-guide.md)).
To make sure the issue is resolved as quickly as possible, please make sure that the reproduction is as **minimal** as possible. This means that you should **remove unnecessary code, files, and dependencies** that do not contribute to the issue. Ensure your reproduction does not depend on secrets, 3rd party registries, private dependencies, or any other data that cannot be made public. Avoid a reproduction including a whole monorepo (unless relevant to the issue). The easier it is to reproduce the issue, the quicker we can help.

View File

@@ -1054,13 +1054,13 @@ import { usePayloadAPI } from '@payloadcms/ui'
const MyComponent: React.FC = () => {
// Fetch data from a collection item using its ID
const [{ data, error, isLoading }, { setParams }] = usePayloadAPI(
const [{ data, isError, isLoading }, { setParams }] = usePayloadAPI(
'/api/posts/123',
{ initialParams: { depth: 1 } }
)
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
if (isError) return <p>Error occurred while fetching data.</p>
return (
<div>
@@ -1094,7 +1094,7 @@ The first item in the returned array is an object containing the following prope
| Property | Description |
| --------------- | -------------------------------------------------------- |
| **`data`** | The API response data. |
| **`error`** | If an error occurs, this contains the error object. |
| **`isError`** | A boolean indicating whether the request failed. |
| **`isLoading`** | A boolean indicating whether the request is in progress. |
The second item is an object with the following methods:

View File

@@ -63,42 +63,41 @@ export default buildConfig({
The following options are available:
| Option | Description |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). |
| **`bin`** | Register custom bin scripts for Payload to execute. |
| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). |
| **`db`** * | The Database Adapter which will be used by Payload. [More details](../database/overview). |
| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. |
| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). |
| **`compatibility`** | Compatibility flags for earlier versions of Payload. [More details](#compatibility-flags). |
| **`globals`** | An array of Globals for Payload to manage. [More details](./globals). |
| **`cors`** | Cross-origin resource sharing (CORS) is a mechanism that accept incoming requests from given domains. You can also customize the `Access-Control-Allow-Headers` header. [More details](#cors). |
| **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). |
| **`logger`** | Logger options, logger options with a destination stream, or an instantiated logger instance. [More details](https://getpino.io/#/docs/api?id=options). |
| **`loggingLevels`** | An object to override the level to use in the logger for Payload's errors. |
| **`graphQL`** | Manage GraphQL-specific functionality, including custom queries and mutations, query complexity limits, etc. [More details](../graphql/overview#graphql-options). |
| **`cookiePrefix`** | A string that will be prefixed to all cookies that Payload sets. |
| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/cookies#csrf-attacks). |
| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). |
| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. |
| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). |
| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. |
| **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). |
| **`routes`** | Control the routing structure that Payload binds itself to. [More details](../admin/overview#root-level-routes). |
| **`email`** | Configure the Email Adapter for Payload to use. [More details](../email/overview). |
| **`onInit`** | A function that is called immediately following startup that receives the Payload instance as its only argument. |
| **`debug`** | Enable to expose more detailed error information. |
| **`telemetry`** | Disable Payload telemetry by passing `false`. [More details](#telemetry). |
| **`rateLimit`** | Control IP-based rate limiting for all Payload resources. Used to prevent DDoS attacks, etc. [More details](../production/preventing-abuse#rate-limiting-requests). |
| **`hooks`** | An array of Root Hooks. [More details](../hooks/overview). |
| **`plugins`** | An array of Plugins. [More details](../plugins/overview). |
| **`endpoints`** | An array of Custom Endpoints added to the Payload router. [More details](../rest-api/overview#custom-endpoints). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
| **`i18n`** | Internationalization configuration. Pass all i18n languages you'd like the admin UI to support. Defaults to English-only. [More details](./i18n). |
| **`secret`** * | A secure, unguessable string that Payload will use for any encryption workflows - for example, password salt / hashing. |
| **`sharp`** | If you would like Payload to offer cropping, focal point selection, and automatic media resizing, install and pass the Sharp module to the config here. |
| **`typescript`** | Configure TypeScript settings here. [More details](#typescript). |
| Option | Description |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). |
| **`bin`** | Register custom bin scripts for Payload to execute. [More Details](#custom-bin-scripts). |
| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). |
| **`db`** * | The Database Adapter which will be used by Payload. [More details](../database/overview). |
| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. |
| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). |
| **`compatibility`** | Compatibility flags for earlier versions of Payload. [More details](#compatibility-flags). |
| **`globals`** | An array of Globals for Payload to manage. [More details](./globals). |
| **`cors`** | Cross-origin resource sharing (CORS) is a mechanism that accept incoming requests from given domains. You can also customize the `Access-Control-Allow-Headers` header. [More details](#cors). |
| **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). |
| **`logger`** | Logger options, logger options with a destination stream, or an instantiated logger instance. [More details](https://getpino.io/#/docs/api?id=options). |
| **`loggingLevels`** | An object to override the level to use in the logger for Payload's errors. |
| **`graphQL`** | Manage GraphQL-specific functionality, including custom queries and mutations, query complexity limits, etc. [More details](../graphql/overview#graphql-options). |
| **`cookiePrefix`** | A string that will be prefixed to all cookies that Payload sets. |
| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/cookies#csrf-attacks). |
| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). |
| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. |
| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). |
| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. |
| **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). |
| **`routes`** | Control the routing structure that Payload binds itself to. [More details](../admin/overview#root-level-routes). |
| **`email`** | Configure the Email Adapter for Payload to use. [More details](../email/overview). |
| **`onInit`** | A function that is called immediately following startup that receives the Payload instance as its only argument. |
| **`debug`** | Enable to expose more detailed error information. |
| **`telemetry`** | Disable Payload telemetry by passing `false`. [More details](#telemetry). |
| **`hooks`** | An array of Root Hooks. [More details](../hooks/overview). |
| **`plugins`** | An array of Plugins. [More details](../plugins/overview). |
| **`endpoints`** | An array of Custom Endpoints added to the Payload router. [More details](../rest-api/overview#custom-endpoints). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
| **`i18n`** | Internationalization configuration. Pass all i18n languages you'd like the admin UI to support. Defaults to English-only. [More details](./i18n). |
| **`secret`** * | A secure, unguessable string that Payload will use for any encryption workflows - for example, password salt / hashing. |
| **`sharp`** | If you would like Payload to offer cropping, focal point selection, and automatic media resizing, install and pass the Sharp module to the config here. |
| **`typescript`** | Configure TypeScript settings here. [More details](#typescript). |
_* An asterisk denotes that a property is required._
@@ -266,3 +265,43 @@ The Payload Config can accept compatibility flags for running the newest version
Payload localization works on a field-by-field basis. As you can nest fields within other fields, you could potentially nest a localized field within a localized field—but this would be redundant and unnecessary. There would be no reason to define a localized field within a localized parent field, given that the entire data structure from the parent field onward would be localized.
By default, Payload will remove the `localized: true` property from sub-fields if a parent field is localized. Set this compatibility flag to `true` only if you have an existing Payload MongoDB database from pre-3.0, and you have nested localized fields that you would like to maintain without migrating.
## Custom bin scripts
Using the `bin` configuration property, you can inject your own scripts to `npx payload`.
Example for `pnpm payload seed`:
Step 1: create `seed.ts` file in the same folder with `payload.config.ts` with:
```ts
import type { SanitizedConfig } from 'payload'
import payload from 'payload'
// Script must define a "script" function export that accepts the sanitized config
export const script = async (config: SanitizedConfig) => {
await payload.init({ config })
await payload.create({ collection: 'pages', data: { title: 'my title' } })
payload.logger.info('Succesffully seeded!')
process.exit(0)
}
```
Step 2: add the `seed` script to `bin`:
```ts
export default buildConfig({
bin: [
{
scriptPath: path.resolve(dirname, 'seed.ts'),
key: 'seed',
},
],
})
```
Now you can run the command using:
```sh
pnpm payload seed
```

View File

@@ -276,7 +276,7 @@ export default async function MyServerComponent({
But, the Payload Config is [non-serializable](https://react.dev/reference/rsc/use-client#serializable-types) by design. It is full of custom validation functions and more. This means that the Payload Config, in its entirety, cannot be passed directly to Client Components.
For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](../admin/hooks#useconfig) hook:
For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](../admin/react-hooks#useconfig) hook:
```tsx
'use client'
@@ -375,7 +375,7 @@ export function MyClientComponent() {
```
<Banner type="success">
See the [Hooks](../admin/hooks) documentation for a full list of available hooks.
See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks.
</Banner>
### Getting the Current Locale
@@ -422,12 +422,12 @@ function Greeting() {
```
<Banner type="success">
See the [Hooks](../admin/hooks) documentation for a full list of available hooks.
See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks.
</Banner>
### Using Hooks
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](../admin/hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts. To do this, you can use one of the many hooks available depending on your needs.
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](../admin/react-hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts. To do this, you can use one of the many hooks available depending on your needs.
```tsx
'use client'
@@ -444,7 +444,7 @@ export function MyClientComponent() {
```
<Banner type="success">
See the [Hooks](../admin/hooks) documentation for a full list of available hooks.
See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks.
</Banner>
### Adding Styles

View File

@@ -79,10 +79,11 @@ export const MyBlocksField: Field = {
The Blocks Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ------------------- | ---------------------------------- |
| **`initCollapsed`** | Set the initial collapsed state |
| **`isSortable`** | Disable order sorting by setting this value to `false` |
| Option | Description |
| ------------------- | -------------------------------------------------------------------------- |
| **`group`** | Text or localization object used to group this Block in the Blocks Drawer. |
| **`initCollapsed`** | Set the initial collapsed state |
| **`isSortable`** | Disable order sorting by setting this value to `false` |
#### Customizing the way your block is rendered in Lexical

View File

@@ -239,7 +239,9 @@ This will add a dropdown to the date picker that allows users to select a timezo
You can customise the available list of timezones in the [global admin config](../admin/overview#timezones).
<Banner type='info'>
<Banner type="info">
**Good to know:**
The date itself will be stored in UTC so it's up to you to handle the conversion to the user's timezone when displaying the date in your frontend.
Dates without a specific time are normalised to 12:00 in the selected timezone.
</Banner>

View File

@@ -121,22 +121,22 @@ powerful Admin UI.
## Config Options
| Option | Description |
|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** * | To be used as the property name when retrieved from the database. [More](./overview#field-names) |
| **`collection`** * | The `slug`s having the relationship field. |
| **`on`** * | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
| **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](../queries/depth#max-depth). |
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
| **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). |
| **`defaultLimit`** | The number of documents to return. Set to 0 to return all related documents. |
| **`defaultSort`** | The field name used to specify the order the joined documents are returned. |
| **`admin`** | Admin-specific configuration. [More details](#admin-config-options). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema. |
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
| Option | Description |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`name`** * | To be used as the property name when retrieved from the database. [More](./overview#field-names) |
| **`collection`** * | The `slug`s having the relationship field or an array of collection slugs. |
| **`on`** * | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. If `collection` is an array, this field must exist for all specified collections |
| **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](../queries/depth#max-depth). |
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
| **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). |
| **`defaultLimit`** | The number of documents to return. Set to 0 to return all related documents. |
| **`defaultSort`** | The field name used to specify the order the joined documents are returned. |
| **`admin`** | Admin-specific configuration. [More details](#admin-config-options). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema. |
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
_* An asterisk denotes that a property is required._
@@ -158,6 +158,7 @@ object with:
- `docs` an array of related documents or only IDs if the depth is reached
- `hasNextPage` a boolean indicating if there are additional documents
- `totalDocs` a total number of documents, exists only if `count: true` is passed to the join query
```json
{
@@ -171,7 +172,39 @@ object with:
}
// { ... }
],
"hasNextPage": false
"hasNextPage": false,
"totalDocs": 10, // if count: true is passed
}
// other fields...
}
```
## Join Field Data (polymorphic)
When a document is returned that for a polymorphic Join field (with `collection` as an array) is populated with related documents. The structure returned is an
object with:
- `docs` an array of `relationTo` - the collection slug of the document and `value` - the document itself or the ID if the depth is reached
- `hasNextPage` a boolean indicating if there are additional documents
- `totalDocs` a total number of documents, exists only if `count: true` is passed to the join query
```json
{
"id": "66e3431a3f23e684075aae9c",
"relatedPosts": {
"docs": [
{
"relationTo": "posts",
"value": {
"id": "66e3431a3f23e684075aaeb9",
// other fields...
"category": "66e3431a3f23e684075aae9c"
}
}
// { ... }
],
"hasNextPage": false,
"totalDocs": 10, // if count: true is passed
}
// other fields...
}
@@ -186,10 +219,11 @@ returning. This is useful for performance reasons when you don't need the relate
The following query options are supported:
| Property | Description |
|-------------|-----------------------------------------------------------------------------------------------------|
| ----------- | --------------------------------------------------------------------------------------------------- |
| **`limit`** | The maximum related documents to be returned, default is 10. |
| **`where`** | An optional `Where` query to filter joined documents. Will be merged with the field `where` object. |
| **`sort`** | A string used to order related results |
| **`count`** | Whether include the count of related documents or not. Not included by default |
These can be applied to the local API, GraphQL, and REST API.
@@ -198,7 +232,8 @@ These can be applied to the local API, GraphQL, and REST API.
By adding `joins` to the local API you can customize the request for each join field by the `name` of the field.
```js
const result = await db.findOne('categories', {
const result = await payload.find({
collection: 'categories',
where: {
title: {
equals: 'My Category'
@@ -218,6 +253,25 @@ const result = await db.findOne('categories', {
})
```
<Banner type="warning">
Currently, `Where` query support on joined documents for join fields with an array of `collection` is limited and not supported for fields inside arrays and blocks.
</Banner>
<Banner type="warning">
Currently, querying by the Join Field itself is not supported, meaning:
```ts
payload.find({
collection: 'categories',
where: {
'relatedPosts.title': { // relatedPosts is a join field
equals: "post"
}
}
})
```
does not work yet.
</Banner>
### Rest API
The rest API supports the same query options as the local API. You can use the `joins` query parameter to customize the

View File

@@ -658,7 +658,7 @@ In addition to the above props, all Server Components will also receive the foll
When swapping out the `Field` component, you are responsible for sending and receiving the field's `value` from the form itself.
To do so, import the [`useField`](../admin/hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value:
To do so, import the [`useField`](../admin/react-hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value:
```tsx
'use client'
@@ -677,7 +677,7 @@ export const CustomTextField: React.FC = () => {
```
<Banner type="success">
For a complete list of all available React hooks, see the [Payload React Hooks](../admin/hooks) documentation. For additional help, see [Building Custom Components](../custom-components/overview#building-custom-components).
For a complete list of all available React hooks, see the [Payload React Hooks](../admin/react-hooks) documentation. For additional help, see [Building Custom Components](../custom-components/overview#building-custom-components).
</Banner>
##### TypeScript#field-component-types

View File

@@ -50,6 +50,7 @@ export const MyRadioField: Field = {
| **`admin`** | Admin-specific configuration. [More details](#admin-options). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`enumName`** | Custom enum name for this field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. |
| **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
| **`virtual`** | Provide `true` to disable field in the database. See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) |

View File

@@ -53,6 +53,7 @@ export const MySelectField: Field = {
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`enumName`** | Custom enum name for this field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. |
| **`dbName`** | Custom table name (if `hasMany` set to `true`) for this field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. |
| **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
| **`virtual`** | Provide `true` to disable field in the database. See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) |

View File

@@ -71,8 +71,7 @@ The following arguments are provided to all Field Hooks:
| **`schemaPath`** | The path of the [Field](../fields/overview) in the schema. |
| **`siblingData`** | The data of sibling fields adjacent to the field that the Hook is running against. |
| **`siblingDocWithLocales`** | The sibling data of the Document with all [Locales](../configuration/localization). |
| **`siblingFields`** | The sibling fields of the field which the hook is running against.
|
| **`siblingFields`** | The sibling fields of the field which the hook is running against. |
| **`value`** | The value of the [Field](../fields/overview). |
<Banner type="success">

View File

@@ -27,7 +27,7 @@ There are four main types of Hooks in Payload:
<Banner type="warning">
**Reminder:**
Payload also ships a set of _React_ hooks that you can use in your frontend application. Although they share a common name, these are very different things and should not be confused. [More details](../admin/hooks).
Payload also ships a set of _React_ hooks that you can use in your frontend application. Although they share a common name, these are very different things and should not be confused. [More details](../admin/react-hooks).
</Banner>
## Root Hooks

View File

@@ -44,3 +44,31 @@ const createdJob = await payload.jobs.queue({
},
})
```
#### Cancelling Jobs
Payload allows you to cancel jobs that are either queued or currently running. When cancelling a running job, the current task will finish executing, but no subsequent tasks will run. This happens because the job checks its cancellation status between tasks.
##### Cancel a Single Job
To cancel a specific job, use the `payload.jobs.cancelByID` method with the job's ID:
```ts
await payload.jobs.cancelByID({
id: createdJob.id,
})
```
##### Cancel Multiple Jobs
To cancel multiple jobs at once, use the `payload.jobs.cancel` method with a `Where` query:
```ts
await payload.jobs.cancel({
where: {
workflowSlug: {
equals: 'createPost',
},
},
})
```

View File

@@ -256,18 +256,14 @@ If you are using relationships or uploads in your front-end application, and you
// ...
// If your site is running on a different domain than your Payload server,
// This will allows requests to be made between the two domains
cors: {
[
'http://localhost:3001' // Your front-end application
],
},
cors: [
'http://localhost:3001' // Your front-end application
],
// If you are protecting resources behind user authentication,
// This will allow cookies to be sent between the two domains
csrf: {
[
'http://localhost:3001' // Your front-end application
],
},
csrf: [
'http://localhost:3001' // Your front-end application
],
}
```

View File

@@ -345,8 +345,7 @@ const result = await payload.login({
email: 'dev@payloadcms.com',
password: 'rip',
},
req: req, // pass a Request object to be provided to all hooks
res: res, // used to automatically set an HTTP-only auth cookie
req: req, // optional, pass a Request object to be provided to all hooks
depth: 2,
locale: 'en',
fallbackLocale: false,
@@ -384,8 +383,7 @@ const result = await payload.resetPassword({
password: req.body.password, // the new password to set
token: 'afh3o2jf2p3f...', // the token generated from the forgotPassword operation
},
req: req, // pass a Request object to be provided to all hooks
res: res, // used to automatically set an HTTP-only auth cookie
req: req, // optional, pass a Request object to be provided to all hooks
})
```
@@ -399,7 +397,7 @@ const result = await payload.unlock({
// required
email: 'dev@payloadcms.com',
},
req: req, // pass a Request object to be provided to all hooks
req: req, // optional, pass a Request object to be provided to all hooks
overrideAccess: true,
})
```

View File

@@ -414,6 +414,15 @@ For more details, see the [Documentation](https://payloadcms.com/docs/getting-st
```
1. The `./src/public` directory is now located directly at root level `./public` [see Next.js docs for details](https://nextjs.org/docs/pages/building-your-application/optimizing/static-assets)
1. Payload now automatically removes `localized: true` property from sub-fields if a parent is localized, as it's redunant and unnecessary. If you have some existing data in this structure and you want to disable that behavior, you need to enable `allowLocalizedWithinLocalized` flag in your payload.config [read more in documentation](https://payloadcms.com/docs/configuration/overview#compatibility-flags), or create a migration script that aligns your data.
Mongodb example for a link in a page layout.
```diff
- layout.columns.en.link.en.type.en
+ layout.columns.en.link.type
```
## Custom Components
1. All Payload React components have been moved from the `payload` package to `@payloadcms/ui`. If you were previously importing components into your app from the `payload` package, for example to create Custom Components, you will need to change your import paths:
@@ -1104,6 +1113,57 @@ plugins: [
If you have custom features for `@payloadcms/richtext-lexical` you will need to migrate your code to the new API. Read more about the new API in the [documentation](https://payloadcms.com/docs/rich-text/building-custom-features).
## Reserved Field names
Payload reserves certain field names for internal use. Using any of the following names in your collections or globals will result in those fields being sanitized from the config, which can cause deployment errors. Ensure that any conflicting fields are renamed before migrating.
### General Reserved Names
- `file`
- `_id` (MongoDB only)
- `__v` (MongoDB only)
**Important Note**: It is recommended to avoid using field names with an underscore (`_`) prefix unless explicitly required by a plugin. Payload uses this prefix for internal columns, which can lead to conflicts in certain SQL conditions. The following are examples of reserved internal columns (this list is not exhaustive and other internal fields may also apply):
- `_order`
- `_path`
- `_uuid`
- `_parent_id`
- `_locale`
### Auth-Related Reserved Names
These are restricted if your collection uses `auth: true` and does not have `disableAuthStrategy: true`:
- `salt`
- `hash`
- `apiKey` (when `auth.useAPIKey: true` is enabled)
- `useAPIKey` (when `auth.useAPIKey: true` is enabled)
- `resetPasswordToken`
- `resetPasswordExpiration`
- `password`
- `email`
- `username`
### Upload-Related Reserved Names
These apply if your collection has `upload: true` configured:
- `filename`
- `mimetype`
- `filesize`
- `width`
- `height`
- `focalX`
- `focalY`
- `url`
- `thumbnailURL`
If `imageSizes` is configured, the following are also reserved:
- `sizes`
If any of these names are found in your collection / global fields, update them before migrating to avoid unexpected issues.
## Upgrade from previous beta
Reference this [community-made site](https://payload-releases-filter.vercel.app/?version=3&from=152429656&to=188243150&sort=asc&breaking=on). Set your version, sort by oldest first, enable breaking changes only.

View File

@@ -6,7 +6,7 @@ desc: Starting to build your own plugin? Find everything you need and learn best
keywords: plugins, template, config, configuration, extensions, custom, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
Building your own [Payload Plugin](./overview) is easy, and if you&apos;re already familiar with Payload then you&apos;ll have everything you need to get started. You can either start from scratch or use the [Plugin Template](#plugin-template) to get up and running quickly.
Building your own [Payload Plugin](./overview) is easy, and if you're already familiar with Payload then you'll have everything you need to get started. You can either start from scratch or use the [Plugin Template](#plugin-template) to get up and running quickly.
<Banner type="success">
To use the template, run `npx create-payload-app@latest --template plugin` directly in
@@ -19,7 +19,7 @@ Our plugin template includes everything you need to build a full life-cycle plug
- A local dev environment to develop the plugin
- Test suite with integrated GitHub workflow
By abstracting your code into a plugin, you&apos;ll be able to reuse your feature across multiple projects and make it available for other developers to use.
By abstracting your code into a plugin, you'll be able to reuse your feature across multiple projects and make it available for other developers to use.
## Plugins Recap
@@ -75,7 +75,7 @@ The purpose of the **dev** folder is to provide a sanitized local Payload projec
Do **not** store any of the plugin functionality in this folder - it is purely an environment to _assist_ you with developing the plugin.
If you&apos;re starting from scratch, you can easily setup a dev environment like this:
If you're starting from scratch, you can easily setup a dev environment like this:
```
mkdir dev
@@ -83,7 +83,7 @@ cd dev
npx create-payload-app@latest
```
If you&apos;re using the plugin template, the dev folder is built out for you and the `samplePlugin` has already been installed in `dev/payload.config.ts`.
If you're using the plugin template, the dev folder is built out for you and the `samplePlugin` has already been installed in `dev/payload.config.ts`.
```
plugins: [
@@ -96,7 +96,7 @@ If you&apos;re using the plugin template, the dev folder is built out for you an
You can add to the `dev/payload.config.ts` and build out the dev project as needed to test your plugin.
When you&apos;re ready to start development, navigate into this folder with `cd dev`
When you're ready to start development, navigate into this folder with `cd dev`
And then start the project with `pnpm dev` and pull up `http://localhost:3000` in your browser.
@@ -108,7 +108,7 @@ A good test suite is essential to ensure quality and stability in your plugin. P
Jest organizes tests into test suites and cases. We recommend creating tests based on the expected behavior of your plugin from start to finish. Read more about tests in the [Jest documentation.](https://jestjs.io/)
The plugin template provides a stubbed out test suite at `dev/plugin.spec.ts` which is ready to go - just add in your own test conditions and you&apos;re all set!
The plugin template provides a stubbed out test suite at `dev/plugin.spec.ts` which is ready to go - just add in your own test conditions and you're all set!
```
let payload: Payload
@@ -160,7 +160,7 @@ export const seed = async (payload: Payload): Promise<void> => {
## Building a Plugin
Now that we have our environment setup and dev project ready to go - it&apos;s time to build the plugin!
Now that we have our environment setup and dev project ready to go - it's time to build the plugin!
```
@@ -217,7 +217,7 @@ To reiterate, the essence of a [Payload Plugin](./overview) is simply to extend
We are going to use spread syntax to allow us to add data to existing arrays without losing the existing data. It is crucial to spread the existing data correctly, else this can cause adverse behavior and conflicts with Payload Config and other plugins.
Let&apos;s say you want to build a plugin that adds a new collection:
Let's say you want to build a plugin that adds a new collection:
```
config.collections = [
@@ -227,7 +227,7 @@ config.collections = [
]
```
First, you need to spread the `config.collections` to ensure that we don&apos;t lose the existing collections. Then you can add any additional collections, just as you would in a regular Payload Config.
First, you need to spread the `config.collections` to ensure that we don't lose the existing collections. Then you can add any additional collections, just as you would in a regular Payload Config.
This same logic is applied to other array and object like properties such as admin, globals and hooks:
@@ -284,7 +284,7 @@ For a better user experience, provide a way to disable the plugin without uninst
### Include tests in your GitHub CI workflow
If you&apos;ve configured tests for your package, integrate them into your workflow to run the tests each time you commit to the plugin repository. Learn more about [how to configure tests into your GitHub CI workflow.](https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-nodejs)
If you've configured tests for your package, integrate them into your workflow to run the tests each time you commit to the plugin repository. Learn more about [how to configure tests into your GitHub CI workflow.](https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-nodejs)
### Publish your finished plugin to npm

View File

@@ -52,7 +52,7 @@ The plugin accepts an object with the following properties:
```ts
type MultiTenantPluginConfig<ConfigTypes = unknown> = {
/**
/**
* After a tenant is deleted, the plugin will attempt to clean up related documents
* - removing documents with the tenant ID
* - removing the tenant from users
@@ -158,6 +158,16 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
rowFields?: never
tenantFieldAccess?: never
}
/**
* Customize tenant selector label
*
* Either a string or an object where the keys are locales and the values are the string labels
*/
tenantSelectorLabel?:
| Partial<{
[key in AcceptedLanguages]?: string
}>
| string
/**
* The slug for the tenant collection
*
@@ -176,6 +186,14 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
* Opt out of adding access constraints to the tenants collection
*/
useTenantsCollectionAccess?: boolean
/**
* Opt out including the baseListFilter to filter tenants by selected tenant
*/
useTenantsListFilter?: boolean
/**
* Opt out including the baseListFilter to filter users by selected tenant
*/
useUsersTenantFilter?: boolean
}
```
@@ -278,6 +296,50 @@ async rewrites() {
}
```
### React Hooks
Below are the hooks exported from the plugin that you can import into your own custom components to consume.
#### useTenantSelection
You can import this like so:
```tsx
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
...
const tenantContext = useTenantSelection()
```
The hook returns the following context:
```ts
type ContextType = {
/**
* Array of options to select from
*/
options: OptionObject[]
/**
* The currently selected tenant ID
*/
selectedTenantID: number | string | undefined
/**
* Prevents a refresh when the tenant is changed
*
* If not switching tenants while viewing a "global", set to true
*/
setPreventRefreshOnChange: React.Dispatch<React.SetStateAction<boolean>>
/**
* Sets the selected tenant ID
*
* @param args.id - The ID of the tenant to select
* @param args.refresh - Whether to refresh the page after changing the tenant
*/
setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void
}
```
## Examples

View File

@@ -10,7 +10,7 @@ Lexical saves data in JSON - this is great for storage and flexibility and allow
## Lexical => JSX
If your frontend uses React, converting Lexical to JSX is the recommended way to render rich text content. Import the `RichText` component from `@payloadcms/richtext-lexical/react` and pass the Lexical content to it:
For React-based frontends, converting Lexical content to JSX is the recommended rendering approach. Import the RichText component from @payloadcms/richtext-lexical/react and pass the Lexical content to it:
```tsx
import React from 'react'
@@ -24,46 +24,130 @@ export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
}
```
The `RichText` component includes built-in serializers for common Lexical nodes but allows customization through the `converters` prop.
In our website template [you have an example](https://github.com/payloadcms/payload/blob/main/templates/website/src/components/RichText/index.tsx) of how to use `converters` to render custom blocks.
The `RichText` component includes built-in serializers for common Lexical nodes but allows customization through the `converters` prop. In our [website template](https://github.com/payloadcms/payload/blob/main/templates/website/src/components/RichText/index.tsx) you have an example of how to use `converters` to render custom blocks, custom nodes and override existing converters.
<Banner type="default">
The JSX converter expects the input data to be fully populated. When fetching data, ensure the `depth` setting is high enough, to ensure that lexical nodes such as uploads are populated.
When fetching data, ensure your `depth` setting is high enough to fully populate Lexical nodes such as uploads. The JSX converter requires fully populated data to work correctly.
</Banner>
### Converting Lexical Blocks to JSX
### Converting Internal Links
In order to convert lexical blocks or inline blocks to JSX, you will have to pass the converter for your block to the RichText component. This converter is not included by default, as Payload doesn't know how to render your custom blocks.
By default, Payload doesn't know how to convert **internal** links to JSX, as it doesn't know what the corresponding URL of the internal link is. You'll notice that you get a "found internal link, but internalDocToHref is not provided" error in the console when you try to render content with internal links.
To fix this, you need to pass the `internalDocToHref` prop to `LinkJSXConverter`. This prop is a function that receives the link node and returns the URL of the document.
```tsx
import React from 'react'
import {
type JSXConvertersFunction,
RichText,
} from '@payloadcms/richtext-lexical/react'
import type { DefaultNodeTypes, SerializedLinkNode } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
import {
type JSXConvertersFunction,
LinkJSXConverter,
RichText,
} from '@payloadcms/richtext-lexical/react'
import React from 'react'
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
const { relationTo, value } = linkNode.fields.doc!
if (typeof value !== 'object') {
throw new Error('Expected value to be an object')
}
const slug = value.slug
return relationTo === 'posts' ? `/posts/${slug}` : `/${slug}`
}
const jsxConverters: JSXConvertersFunction<DefaultNodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
...LinkJSXConverter({ internalDocToHref }),
})
export const MyComponent: React.FC<{
lexicalData: SerializedEditorState
}> = ({ lexicalData }) => {
return <RichText converters={jsxConverters} data={lexicalData} />
}
```
### Converting Lexical Blocks
To convert Lexical Blocks or Inline Blocks to JSX, pass the converter for your block to the `RichText` component. This converter is not included by default, as Payload doesn't know how to render your custom blocks.
```tsx
'use client'
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
import React from 'react'
// Extend the default node types with your custom blocks for full type safety
type NodeTypes = DefaultNodeTypes | SerializedBlockNode<MyInlineBlock | MyTextBlock>
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
// myTextBlock is the slug of the block
// Each key should match your block's slug
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
},
inlineBlocks: {
// myInlineBlock is the slug of the block
// Each key should match your inline block's slug
myInlineBlock: ({ node }) => <span>{node.fields.text}</span>,
},
})
export const MyComponent = ({ lexicalData }) => {
return (
<RichText
converters={jsxConverters}
data={lexicalData.lexicalWithBlocks as SerializedEditorState}
/>
)
export const MyComponent: React.FC<{
lexicalData: SerializedEditorState
}> = ({ lexicalData }) => {
return <RichText converters={jsxConverters} data={lexicalData} />
}
```
### Overriding Default JSX Converters
You can override any of the default JSX converters by passing passing your custom converter, keyed to the node type, to the `converters` prop / the converters function.
Example - overriding the upload node converter to use next/image:
```tsx
'use client'
import type { DefaultNodeTypes, SerializedUploadNode } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
import Image from 'next/image'
import React from 'react'
type NodeTypes = DefaultNodeTypes
// Custom upload converter component that uses next/image
const CustomUploadComponent: React.FC<{
node: SerializedUploadNode
}> = ({ node }) => {
if (node.relationTo === 'uploads') {
const uploadDoc = node.value
if (typeof uploadDoc !== 'object') {
return null
}
const { alt, height, url, width } = uploadDoc
return <Image alt={alt} height={height} src={url} width={width} />
}
return null
}
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
// Override the default upload converter
upload: ({ node }) => {
return <CustomUploadComponent node={node} />
},
})
export const MyComponent: React.FC<{
lexicalData: SerializedEditorState
}> = ({ lexicalData }) => {
return <RichText converters={jsxConverters} data={lexicalData} />
}
```

View File

@@ -29,9 +29,9 @@ Using the BlocksFeature, you can add both inline blocks (= can be inserted into
### Example: Code Field Block with language picker
This example demonstrates how to create a custom code field block with a language picker using the `BlocksFeature`. Make sure to manually install `@payloadcms/ui`first.
This example demonstrates how to create a custom code field block with a language picker using the `BlocksFeature`. First, make sure to explicitly install `@payloadcms/ui` in your project.
Field config:
Field Config:
```ts
import {
@@ -91,7 +91,6 @@ CodeComponent.tsx:
```tsx
'use client'
import type { CodeFieldClient, CodeFieldClientProps } from 'payload'
import { CodeField, useFormFields } from '@payloadcms/ui'
@@ -105,6 +104,8 @@ const languageKeyToMonacoLanguageMap = {
tsx: 'typescript',
}
type Language = keyof typeof languageKeyToMonacoLanguageMap
export const Code: React.FC<CodeFieldClientProps> = ({
autoComplete,
field,
@@ -118,10 +119,10 @@ export const Code: React.FC<CodeFieldClientProps> = ({
}) => {
const languageField = useFormFields(([fields]) => fields['language'])
const language: string =
(languageField?.value as string) || (languageField.initialValue as string) || 'typescript'
const language: Language =
(languageField?.value as Language) || (languageField?.initialValue as Language) || 'ts'
const label = languages[language as keyof typeof languages]
const label = languages[language]
const props: CodeFieldClient = useMemo<CodeFieldClient>(
() => ({
@@ -129,9 +130,10 @@ export const Code: React.FC<CodeFieldClientProps> = ({
type: 'code',
admin: {
...field.admin,
label,
editorOptions: undefined,
language: languageKeyToMonacoLanguageMap[language] || language,
},
label,
}),
[field, language, label],
)

View File

@@ -145,13 +145,13 @@ Here's an overview of all the included features:
| Feature Name | Included by default | Description |
| --- | --- | --- |
| **`BoldTextFeature`** | Yes | Handles the bold text format |
| **`ItalicTextFeature`** | Yes | Handles the italic text format |
| **`UnderlineTextFeature`** | Yes | Handles the underline text format |
| **`StrikethroughTextFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptTextFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptTextFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeTextFeature`** | Yes | Handles the inline-code text format |
| **`BoldFeature`** | Yes | Handles the bold text format |
| **`ItalicFeature`** | Yes | Handles the italic text format |
| **`UnderlineFeature`** | Yes | Handles the underline text format |
| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeFeature`** | Yes | Handles the inline-code text format |
| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |

View File

@@ -30,6 +30,7 @@ pnpm add @payloadcms/storage-vercel-blob
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
- Ensure you have `BLOB_READ_WRITE_TOKEN` set in your Vercel environment variables. This is usually set by Vercel automatically after adding blob storage to your project.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client.
```ts
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
@@ -64,6 +65,7 @@ export default buildConfig({
| `addRandomSuffix` | Add a random suffix to the uploaded file name in Vercel Blob storage | `false` |
| `cacheControlMaxAge` | Cache-Control max-age in seconds | `365 * 24 * 60 * 60` (1 Year) |
| `token` | Vercel Blob storage read/write token | `''` |
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
## S3 Storage
[`@payloadcms/storage-s3`](https://www.npmjs.com/package/@payloadcms/storage-s3)
@@ -79,6 +81,7 @@ pnpm add @payloadcms/storage-s3
- Configure the `collections` object to specify which collections should use the S3 Storage adapter. The slug _must_ match one of your existing collection slugs.
- The `config` object can be any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object (from [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3)). _This is highly dependent on your AWS setup_. Check the AWS documentation for more information.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method for the bucket to your website.
```ts
import { s3Storage } from '@payloadcms/storage-s3'
@@ -126,6 +129,7 @@ pnpm add @payloadcms/storage-azure
- Configure the `collections` object to specify which collections should use the Azure Blob adapter. The slug _must_ match one of your existing collection slugs.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method to your website.
```ts
import { azureStorage } from '@payloadcms/storage-azure'
@@ -161,6 +165,7 @@ export default buildConfig({
| `baseURL` | Base URL for the Azure Blob storage account | |
| `connectionString` | Azure Blob storage connection string | |
| `containerName` | Azure Blob storage container name | |
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
## Google Cloud Storage
[`@payloadcms/storage-gcs`](https://www.npmjs.com/package/@payloadcms/storage-gcs)
@@ -175,6 +180,7 @@ pnpm add @payloadcms/storage-gcs
- Configure the `collections` object to specify which collections should use the Google Cloud Storage adapter. The slug _must_ match one of your existing collection slugs.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method for the bucket to your website.
```ts
import { gcsStorage } from '@payloadcms/storage-gcs'
@@ -203,13 +209,14 @@ export default buildConfig({
### Configuration Options#gcs-configuration
| Option | Description | Default |
| ------------- | --------------------------------------------------------------------------------------------------- | --------- |
| `enabled` | Whether or not to enable the plugin | `true` |
| `collections` | Collections to apply the storage to | |
| `bucket` | The name of the bucket to use | |
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
| `acl` | Access control list for files that are uploaded | `Private` |
| Option | Description | Default |
| --------------- | --------------------------------------------------------------------------------------------------- | --------- |
| `enabled` | Whether or not to enable the plugin | `true` |
| `collections` | Collections to apply the storage to | |
| `bucket` | The name of the bucket to use | |
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
| `acl` | Access control list for files that are uploaded | `Private` |
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
## Uploadthing Storage
@@ -226,6 +233,7 @@ pnpm add @payloadcms/storage-uploadthing
- Configure the `collections` object to specify which collections should use uploadthing. The slug _must_ match one of your existing collection slugs and be an `upload` type.
- Get a token from Uploadthing and set it as `token` in the `options` object.
- `acl` is optional and defaults to `public-read`.
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client.
```ts
export default buildConfig({
@@ -246,13 +254,14 @@ export default buildConfig({
### Configuration Options#uploadthing-configuration
| Option | Description | Default |
| ---------------- | ----------------------------------------------- | ------------- |
| `token` | Token from Uploadthing. Required. | |
| `acl` | Access control list for files that are uploaded | `public-read` |
| `logLevel` | Log level for Uploadthing | `info` |
| `fetch` | Custom fetch function | `fetch` |
| `defaultKeyType` | Default key type for file operations | `fileKey` |
| Option | Description | Default |
| ---------------- | ------------------------------------------------------------- | ------------- |
| `token` | Token from Uploadthing. Required. | |
| `acl` | Access control list for files that are uploaded | `public-read` |
| `logLevel` | Log level for Uploadthing | `info` |
| `fetch` | Custom fetch function | `fetch` |
| `defaultKeyType` | Default key type for file operations | `fileKey` |
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
## Custom Storage Adapters

View File

@@ -1,7 +1,6 @@
import type { CollectionAfterLoginHook } from 'payload'
import { mergeHeaders } from '@payloadcms/next/utilities'
import { generateCookie, getCookieExpiration } from 'payload'
import { mergeHeaders, generateCookie, getCookieExpiration } from 'payload'
export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, user }) => {
const relatedOrg = await req.payload.find({

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.23.0",
"version": "3.25.0",
"private": true,
"type": "module",
"scripts": {
@@ -117,7 +117,7 @@
"devDependencies": {
"@jest/globals": "29.7.0",
"@libsql/client": "0.14.0",
"@next/bundle-analyzer": "15.1.5",
"@next/bundle-analyzer": "15.2.0",
"@payloadcms/db-postgres": "workspace:*",
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/eslint-plugin": "workspace:*",
@@ -132,8 +132,8 @@
"@types/jest": "29.5.12",
"@types/minimist": "1.2.5",
"@types/node": "22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/shelljs": "0.8.15",
"chalk": "^4.1.2",
"comment-json": "^4.2.3",
@@ -153,7 +153,7 @@
"lint-staged": "15.2.7",
"minimist": "1.2.8",
"mongodb-memory-server": "^10",
"next": "15.1.5",
"next": "15.2.0",
"open": "^10.1.0",
"p-limit": "^5.0.0",
"playwright": "1.50.0",
@@ -173,10 +173,6 @@
"turbo": "^2.3.3",
"typescript": "5.7.3"
},
"peerDependencies": {
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
},
"packageManager": "pnpm@9.7.1",
"engines": {
"node": "^18.20.2 || >=20.9.0",

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import type { ConnectOptions } from 'mongoose'
import type { Connect } from 'payload'
import mongoose from 'mongoose'
import { captureError, defaultBeginTransaction } from 'payload'
import { defaultBeginTransaction } from 'payload'
import type { MongooseAdapter } from './index.js'
@@ -70,12 +70,10 @@ export const connect: Connect = async function connect(
await this.migrate({ migrations: this.prodMigrations })
}
} catch (err) {
await captureError({
this.payload.logger.error({
err,
msg: `Error: cannot connect to MongoDB. Details: ${err.message}`,
payload: this.payload,
})
process.exit(1)
}
}

View File

@@ -1,15 +1,15 @@
import type { CreateOptions } from 'mongoose'
import type { Create, Document } from 'payload'
import type { Create } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { handleError } from './utilities/handleError.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const create: Create = async function create(
this: MongooseAdapter,
{ collection, data, req },
{ collection, data, req, returning },
) {
const Model = this.collections[collection]
const options: CreateOptions = {
@@ -18,31 +18,34 @@ export const create: Create = async function create(
let doc
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
transform({
adapter: this,
data,
fields: this.payload.collections[collection].config.fields,
operation: 'write',
})
if (this.payload.collections[collection].customIDType) {
sanitizedData._id = sanitizedData.id
data._id = data.id
}
try {
;[doc] = await Model.create([sanitizedData], options)
;[doc] = await Model.create([data], options)
} catch (error) {
handleError({ collection, error, req })
}
// doc.toJSON does not do stuff like converting ObjectIds to string, or date strings to date objects. That's why we use JSON.parse/stringify here
const result: Document = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
if (returning === false) {
return null
}
return result
doc = doc.toObject()
transform({
adapter: this,
data: doc,
fields: this.payload.collections[collection].config.fields,
operation: 'read',
})
return doc
}

View File

@@ -4,35 +4,39 @@ import type { CreateGlobal } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const createGlobal: CreateGlobal = async function createGlobal(
this: MongooseAdapter,
{ slug, data, req },
{ slug, data, req, returning },
) {
const Model = this.globals
const global = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
globalType: slug,
...data,
},
transform({
adapter: this,
data,
fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields,
globalSlug: slug,
operation: 'write',
})
const options: CreateOptions = {
session: await getSession(this, req),
}
let [result] = (await Model.create([global], options)) as any
let [result] = (await Model.create([data], options)) as any
if (returning === false) {
return null
}
result = JSON.parse(JSON.stringify(result))
result = result.toObject()
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
transform({
adapter: this,
data: result,
fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields,
operation: 'read',
})
return result
}

View File

@@ -1,11 +1,11 @@
import type { CreateOptions } from 'mongoose'
import { buildVersionGlobalFields, type CreateGlobalVersion, type Document } from 'payload'
import { buildVersionGlobalFields, type CreateGlobalVersion } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const createGlobalVersion: CreateGlobalVersion = async function createGlobalVersion(
this: MongooseAdapter,
@@ -16,6 +16,7 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
parent,
publishedLocale,
req,
returning,
snapshot,
updatedAt,
versionData,
@@ -26,25 +27,30 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
session: await getSession(this, req),
}
const data = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
},
fields: buildVersionGlobalFields(
this.payload.config,
this.payload.config.globals.find((global) => global.slug === globalSlug),
),
const data = {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
}
const fields = buildVersionGlobalFields(
this.payload.config,
this.payload.config.globals.find((global) => global.slug === globalSlug),
)
transform({
adapter: this,
data,
fields,
operation: 'write',
})
const [doc] = await VersionModel.create([data], options, req)
let [doc] = await VersionModel.create([data], options, req)
await VersionModel.updateMany(
{
@@ -70,13 +76,18 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
options,
)
const result: Document = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
if (returning === false) {
return null
}
return result
doc = doc.toObject()
transform({
adapter: this,
data: doc,
fields,
operation: 'read',
})
return doc
}

View File

@@ -1,12 +1,11 @@
import type { CreateOptions } from 'mongoose'
import { Types } from 'mongoose'
import { buildVersionCollectionFields, type CreateVersion, type Document } from 'payload'
import { buildVersionCollectionFields, type CreateVersion } from 'payload'
import type { MongooseAdapter } from './index.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const createVersion: CreateVersion = async function createVersion(
this: MongooseAdapter,
@@ -17,6 +16,7 @@ export const createVersion: CreateVersion = async function createVersion(
parent,
publishedLocale,
req,
returning,
snapshot,
updatedAt,
versionData,
@@ -27,25 +27,30 @@ export const createVersion: CreateVersion = async function createVersion(
session: await getSession(this, req),
}
const data = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
},
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collectionSlug].config,
),
const data = {
autosave,
createdAt,
latest: true,
parent,
publishedLocale,
snapshot,
updatedAt,
version: versionData,
}
const fields = buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collectionSlug].config,
)
transform({
adapter: this,
data,
fields,
operation: 'write',
})
const [doc] = await VersionModel.create([data], options, req)
let [doc] = await VersionModel.create([data], options, req)
const parentQuery = {
$or: [
@@ -56,13 +61,6 @@ export const createVersion: CreateVersion = async function createVersion(
},
],
}
if (data.parent instanceof Types.ObjectId) {
parentQuery.$or.push({
parent: {
$eq: data.parent.toString(),
},
})
}
await VersionModel.updateMany(
{
@@ -89,13 +87,18 @@ export const createVersion: CreateVersion = async function createVersion(
options,
)
const result: Document = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
if (returning === false) {
return null
}
return result
doc = doc.toObject()
transform({
adapter: this,
data: doc,
fields,
operation: 'read',
})
return doc
}

View File

@@ -1,19 +1,19 @@
import type { QueryOptions } from 'mongoose'
import type { DeleteOne, Document } from 'payload'
import type { MongooseUpdateQueryOptions } from 'mongoose'
import type { DeleteOne } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const deleteOne: DeleteOne = async function deleteOne(
this: MongooseAdapter,
{ collection, req, select, where },
{ collection, req, returning, select, where },
) {
const Model = this.collections[collection]
const options: QueryOptions = {
const options: MongooseUpdateQueryOptions = {
projection: buildProjectionFromSelect({
adapter: this,
fields: this.payload.collections[collection].config.flattenedFields,
@@ -29,13 +29,23 @@ export const deleteOne: DeleteOne = async function deleteOne(
where,
})
const doc = await Model.findOneAndDelete(query, options).lean()
if (returning === false) {
await Model.deleteOne(query, options)?.lean()
return null
}
let result: Document = JSON.parse(JSON.stringify(doc))
const doc = await Model.findOneAndDelete(query, options)?.lean()
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
if (!doc) {
return null
}
return result
transform({
adapter: this,
data: doc,
fields: this.payload.collections[collection].config.fields,
operation: 'read',
})
return doc
}

View File

@@ -10,7 +10,7 @@ import { buildSortParam } from './queries/buildSortParam.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const find: Find = async function find(
this: MongooseAdapter,
@@ -133,13 +133,12 @@ export const find: Find = async function find(
result = await Model.paginate(query, paginationOptions)
}
const docs = JSON.parse(JSON.stringify(result.docs))
transform({
adapter: this,
data: result.docs,
fields: this.payload.collections[collection].config.fields,
operation: 'read',
})
return {
...result,
docs: docs.map((doc) => {
doc.id = doc._id
return sanitizeInternalFields(doc)
}),
}
return result
}

View File

@@ -8,14 +8,15 @@ import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findGlobal: FindGlobal = async function findGlobal(
this: MongooseAdapter,
{ slug, locale, req, select, where },
) {
const Model = this.globals
const fields = this.payload.globals.config.find((each) => each.slug === slug).flattenedFields
const globalConfig = this.payload.globals.config.find((each) => each.slug === slug)
const fields = globalConfig.flattenedFields
const options: QueryOptions = {
lean: true,
select: buildProjectionFromSelect({
@@ -34,18 +35,18 @@ export const findGlobal: FindGlobal = async function findGlobal(
where: combineQueries({ globalType: { equals: slug } }, where),
})
let doc = (await Model.findOne(query, {}, options)) as any
const doc = (await Model.findOne(query, {}, options)) as any
if (!doc) {
return null
}
if (doc._id) {
doc.id = doc._id
delete doc._id
}
doc = JSON.parse(JSON.stringify(doc))
doc = sanitizeInternalFields(doc)
transform({
adapter: this,
data: doc,
fields: globalConfig.fields,
operation: 'read',
})
return doc
}

View File

@@ -9,18 +9,15 @@ import { buildQuery } from './queries/buildQuery.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findGlobalVersions: FindGlobalVersions = async function findGlobalVersions(
this: MongooseAdapter,
{ global, limit, locale, page, pagination, req, select, skip, sort: sortArg, where },
) {
const globalConfig = this.payload.globals.config.find(({ slug }) => slug === global)
const Model = this.versions[global]
const versionFields = buildVersionGlobalFields(
this.payload.config,
this.payload.globals.config.find(({ slug }) => slug === global),
true,
)
const versionFields = buildVersionGlobalFields(this.payload.config, globalConfig, true)
const session = await getSession(this, req)
const options: QueryOptions = {
@@ -103,13 +100,13 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
}
const result = await Model.paginate(query, paginationOptions)
const docs = JSON.parse(JSON.stringify(result.docs))
return {
...result,
docs: docs.map((doc) => {
doc.id = doc._id
return sanitizeInternalFields(doc)
}),
}
transform({
adapter: this,
data: result.docs,
fields: buildVersionGlobalFields(this.payload.config, globalConfig),
operation: 'read',
})
return result
}

View File

@@ -1,5 +1,5 @@
import type { AggregateOptions, QueryOptions } from 'mongoose'
import type { Document, FindOne } from 'payload'
import type { FindOne } from 'payload'
import type { MongooseAdapter } from './index.js'
@@ -7,7 +7,7 @@ import { buildQuery } from './queries/buildQuery.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findOne: FindOne = async function findOne(
this: MongooseAdapter,
@@ -58,11 +58,7 @@ export const findOne: FindOne = async function findOne(
return null
}
let result: Document = JSON.parse(JSON.stringify(doc))
transform({ adapter: this, data: doc, fields: collectionConfig.fields, operation: 'read' })
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
return result
return doc
}

View File

@@ -9,7 +9,7 @@ import { buildQuery } from './queries/buildQuery.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const findVersions: FindVersions = async function findVersions(
this: MongooseAdapter,
@@ -104,13 +104,13 @@ export const findVersions: FindVersions = async function findVersions(
}
const result = await Model.paginate(query, paginationOptions)
const docs = JSON.parse(JSON.stringify(result.docs))
return {
...result,
docs: docs.map((doc) => {
doc.id = doc._id
return sanitizeInternalFields(doc)
}),
}
transform({
adapter: this,
data: result.docs,
fields: buildVersionCollectionFields(this.payload.config, collectionConfig),
operation: 'read',
})
return result
}

View File

@@ -16,6 +16,7 @@ import type {
TypeWithVersion,
UpdateGlobalArgs,
UpdateGlobalVersionArgs,
UpdateManyArgs,
UpdateOneArgs,
UpdateVersionArgs,
} from 'payload'
@@ -53,6 +54,7 @@ import { commitTransaction } from './transactions/commitTransaction.js'
import { rollbackTransaction } from './transactions/rollbackTransaction.js'
import { updateGlobal } from './updateGlobal.js'
import { updateGlobalVersion } from './updateGlobalVersion.js'
import { updateMany } from './updateMany.js'
import { updateOne } from './updateOne.js'
import { updateVersion } from './updateVersion.js'
import { upsert } from './upsert.js'
@@ -160,6 +162,7 @@ declare module 'payload' {
updateGlobalVersion: <T extends TypeWithID = TypeWithID>(
args: { options?: QueryOptions } & UpdateGlobalVersionArgs<T>,
) => Promise<TypeWithVersion<T>>
updateOne: (args: { options?: QueryOptions } & UpdateOneArgs) => Promise<Document>
updateVersion: <T extends TypeWithID = TypeWithID>(
args: { options?: QueryOptions } & UpdateVersionArgs<T>,
@@ -200,6 +203,7 @@ export function mongooseAdapter({
mongoMemoryServer,
sessions: {},
transactionOptions: transactionOptions === false ? undefined : transactionOptions,
updateMany,
url,
versions: {},
// DatabaseAdapter

View File

@@ -1,11 +1,6 @@
import {
captureError,
commitTransaction,
createLocalReq,
initTransaction,
killTransaction,
readMigrationFiles,
} from 'payload'
import type { PayloadRequest } from 'payload'
import { commitTransaction, initTransaction, killTransaction, readMigrationFiles } from 'payload'
import prompts from 'prompts'
import type { MongooseAdapter } from './index.js'
@@ -50,7 +45,7 @@ export async function migrateFresh(
msg: `Found ${migrationFiles.length} migration files.`,
})
const req = await createLocalReq({}, payload)
const req = { payload }
// Run all migrate up
for (const migration of migrationFiles) {
@@ -73,12 +68,10 @@ export async function migrateFresh(
payload.logger.info({ msg: `Migrated: ${migration.name} (${Date.now() - start}ms)` })
} catch (err: unknown) {
await killTransaction(req)
await captureError({
payload.logger.error({
err,
msg: `Error running migration ${migration.name}. Rolling back.`,
req,
})
throw err
}
}

View File

@@ -476,6 +476,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
if (fieldShouldBeLocalized({ field, parentIsLocalized }) && payload.config.localization) {
schemaToReturn = {
_id: false,
type: payload.config.localization.localeCodes.reduce((locales, locale) => {
let localeSchema: { [key: string]: any } = {}
@@ -698,6 +699,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
if (fieldShouldBeLocalized({ field, parentIsLocalized }) && payload.config.localization) {
schemaToReturn = {
_id: false,
type: payload.config.localization.localeCodes.reduce((locales, locale) => {
let localeSchema: { [key: string]: any } = {}

View File

@@ -6,11 +6,12 @@ import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
import type { MongooseAdapter } from '../index.js'
import { getSession } from '../utilities/getSession.js'
import { sanitizeRelationshipIDs } from '../utilities/sanitizeRelationshipIDs.js'
import { transform } from '../utilities/transform.js'
const migrateModelWithBatching = async ({
batchSize,
config,
db,
fields,
Model,
parentIsLocalized,
@@ -18,6 +19,7 @@ const migrateModelWithBatching = async ({
}: {
batchSize: number
config: SanitizedConfig
db: MongooseAdapter
fields: Field[]
Model: Model<any>
parentIsLocalized: boolean
@@ -49,7 +51,7 @@ const migrateModelWithBatching = async ({
}
for (const doc of docs) {
sanitizeRelationshipIDs({ config, data: doc, fields, parentIsLocalized })
transform({ adapter: db, data: doc, fields, operation: 'write', parentIsLocalized })
}
await Model.collection.bulkWrite(
@@ -124,6 +126,7 @@ export async function migrateRelationshipsV2_V3({
await migrateModelWithBatching({
batchSize,
config,
db,
fields: collection.fields,
Model: db.collections[collection.slug],
parentIsLocalized: false,
@@ -139,6 +142,7 @@ export async function migrateRelationshipsV2_V3({
await migrateModelWithBatching({
batchSize,
config,
db,
fields: buildVersionCollectionFields(config, collection),
Model: db.versions[collection.slug],
parentIsLocalized: false,
@@ -167,10 +171,11 @@ export async function migrateRelationshipsV2_V3({
// in case if the global doesn't exist in the database yet (not saved)
if (doc) {
sanitizeRelationshipIDs({
config,
transform({
adapter: db,
data: doc,
fields: global.fields,
operation: 'write',
})
await GlobalsModel.collection.updateOne(
@@ -191,6 +196,7 @@ export async function migrateRelationshipsV2_V3({
await migrateModelWithBatching({
batchSize,
config,
db,
fields: buildVersionGlobalFields(config, global),
Model: db.versions[global.slug],
parentIsLocalized: false,

View File

@@ -255,6 +255,25 @@ export async function buildSearchParam({
return result
}
if (formattedOperator === 'not_like' && typeof formattedValue === 'string') {
const words = formattedValue.split(' ')
const result = {
value: {
$and: words.map((word) => ({
[path]: {
$not: {
$options: 'i',
$regex: word.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'),
},
},
})),
},
}
return result
}
// Some operators like 'near' need to define a full query
// so if there is no operator key, just return the value
if (!operatorKey) {

View File

@@ -10,7 +10,7 @@ import { buildSortParam } from './queries/buildSortParam.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { transform } from './utilities/transform.js'
export const queryDrafts: QueryDrafts = async function queryDrafts(
this: MongooseAdapter,
@@ -124,18 +124,18 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
result = await VersionModel.paginate(versionQuery, paginationOptions)
}
const docs = JSON.parse(JSON.stringify(result.docs))
transform({
adapter: this,
data: result.docs,
fields: buildVersionCollectionFields(this.payload.config, collectionConfig),
operation: 'read',
})
return {
...result,
docs: docs.map((doc) => {
doc = {
_id: doc.parent,
id: doc.parent,
...doc.version,
}
return sanitizeInternalFields(doc)
}),
for (let i = 0; i < result.docs.length; i++) {
const id = result.docs[i].parent
result.docs[i] = result.docs[i].version
result.docs[i].id = id
}
return result
}

View File

@@ -1,21 +1,20 @@
import type { QueryOptions } from 'mongoose'
import type { MongooseUpdateQueryOptions } from 'mongoose'
import type { UpdateGlobal } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const updateGlobal: UpdateGlobal = async function updateGlobal(
this: MongooseAdapter,
{ slug, data, options: optionsArgs = {}, req, select },
{ slug, data, options: optionsArgs = {}, req, returning, select },
) {
const Model = this.globals
const fields = this.payload.config.globals.find((global) => global.slug === slug).fields
const options: QueryOptions = {
const options: MongooseUpdateQueryOptions = {
...optionsArgs,
lean: true,
new: true,
@@ -27,21 +26,16 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal(
session: await getSession(this, req),
}
let result
transform({ adapter: this, data, fields, globalSlug: slug, operation: 'write' })
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data,
fields,
})
if (returning === false) {
await Model.updateOne({ globalType: slug }, data, options)
return null
}
result = await Model.findOneAndUpdate({ globalType: slug }, sanitizedData, options)
const result: any = await Model.findOneAndUpdate({ globalType: slug }, data, options)
result = JSON.parse(JSON.stringify(result))
// custom id type reset
result.id = result._id
result = sanitizeInternalFields(result)
transform({ adapter: this, data: result, fields, globalSlug: slug, operation: 'read' })
return result
}

View File

@@ -1,4 +1,4 @@
import type { QueryOptions } from 'mongoose'
import type { MongooseUpdateQueryOptions } from 'mongoose'
import { buildVersionGlobalFields, type TypeWithID, type UpdateGlobalVersionArgs } from 'payload'
@@ -7,7 +7,7 @@ import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export async function updateGlobalVersion<T extends TypeWithID>(
this: MongooseAdapter,
@@ -17,6 +17,7 @@ export async function updateGlobalVersion<T extends TypeWithID>(
locale,
options: optionsArgs = {},
req,
returning,
select,
versionData,
where,
@@ -28,7 +29,7 @@ export async function updateGlobalVersion<T extends TypeWithID>(
const currentGlobal = this.payload.config.globals.find((global) => global.slug === globalSlug)
const fields = buildVersionGlobalFields(this.payload.config, currentGlobal)
const flattenedFields = buildVersionGlobalFields(this.payload.config, currentGlobal, true)
const options: QueryOptions = {
const options: MongooseUpdateQueryOptions = {
...optionsArgs,
lean: true,
new: true,
@@ -47,22 +48,20 @@ export async function updateGlobalVersion<T extends TypeWithID>(
where: whereToUse,
})
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data: versionData,
fields,
})
transform({ adapter: this, data: versionData, fields, operation: 'write' })
const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options)
const result = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
if (returning === false) {
await VersionModel.updateOne(query, versionData, options)
return null
}
return result
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
if (!doc) {
return null
}
transform({ adapter: this, data: doc, fields, operation: 'read' })
return doc
}

View File

@@ -0,0 +1,61 @@
import type { MongooseUpdateQueryOptions } from 'mongoose'
import type { UpdateMany } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { handleError } from './utilities/handleError.js'
import { transform } from './utilities/transform.js'
export const updateMany: UpdateMany = async function updateMany(
this: MongooseAdapter,
{ collection, data, locale, options: optionsArgs = {}, req, returning, select, where },
) {
const Model = this.collections[collection]
const fields = this.payload.collections[collection].config.fields
const options: MongooseUpdateQueryOptions = {
...optionsArgs,
lean: true,
new: true,
projection: buildProjectionFromSelect({
adapter: this,
fields: this.payload.collections[collection].config.flattenedFields,
select,
}),
session: await getSession(this, req),
}
const query = await buildQuery({
adapter: this,
collectionSlug: collection,
fields: this.payload.collections[collection].config.flattenedFields,
locale,
where,
})
transform({ adapter: this, data, fields, operation: 'write' })
try {
await Model.updateMany(query, data, options)
} catch (error) {
handleError({ collection, error, req })
}
if (returning === false) {
return null
}
const result = await Model.find(query, {}, options)
transform({
adapter: this,
data: result,
fields,
operation: 'read',
})
return result
}

View File

@@ -1,4 +1,4 @@
import type { QueryOptions } from 'mongoose'
import type { MongooseUpdateQueryOptions } from 'mongoose'
import type { UpdateOne } from 'payload'
import type { MongooseAdapter } from './index.js'
@@ -7,17 +7,26 @@ import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { handleError } from './utilities/handleError.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const updateOne: UpdateOne = async function updateOne(
this: MongooseAdapter,
{ id, collection, data, locale, options: optionsArgs = {}, req, select, where: whereArg },
{
id,
collection,
data,
locale,
options: optionsArgs = {},
req,
returning,
select,
where: whereArg,
},
) {
const where = id ? { id: { equals: id } } : whereArg
const Model = this.collections[collection]
const fields = this.payload.collections[collection].config.fields
const options: QueryOptions = {
const options: MongooseUpdateQueryOptions = {
...optionsArgs,
lean: true,
new: true,
@@ -39,21 +48,24 @@ export const updateOne: UpdateOne = async function updateOne(
let result
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data,
fields,
})
transform({ adapter: this, data, fields, operation: 'write' })
try {
result = await Model.findOneAndUpdate(query, sanitizedData, options)
if (returning === false) {
await Model.updateOne(query, data, options)
return null
} else {
result = await Model.findOneAndUpdate(query, data, options)
}
} catch (error) {
handleError({ collection, error, req })
}
result = JSON.parse(JSON.stringify(result))
result.id = result._id
result = sanitizeInternalFields(result)
if (!result) {
return null
}
transform({ adapter: this, data: result, fields, operation: 'read' })
return result
}

View File

@@ -1,4 +1,4 @@
import type { QueryOptions } from 'mongoose'
import type { MongooseUpdateQueryOptions } from 'mongoose'
import { buildVersionCollectionFields, type UpdateVersion } from 'payload'
@@ -7,11 +7,11 @@ import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { transform } from './utilities/transform.js'
export const updateVersion: UpdateVersion = async function updateVersion(
this: MongooseAdapter,
{ id, collection, locale, options: optionsArgs = {}, req, select, versionData, where },
{ id, collection, locale, options: optionsArgs = {}, req, returning, select, versionData, where },
) {
const VersionModel = this.versions[collection]
const whereToUse = where || { id: { equals: id } }
@@ -26,7 +26,7 @@ export const updateVersion: UpdateVersion = async function updateVersion(
true,
)
const options: QueryOptions = {
const options: MongooseUpdateQueryOptions = {
...optionsArgs,
lean: true,
new: true,
@@ -45,22 +45,20 @@ export const updateVersion: UpdateVersion = async function updateVersion(
where: whereToUse,
})
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data: versionData,
fields,
})
transform({ adapter: this, data: versionData, fields, operation: 'write' })
const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options)
const result = JSON.parse(JSON.stringify(doc))
const verificationToken = doc._verificationToken
// custom id type reset
result.id = result._id
if (verificationToken) {
result._verificationToken = verificationToken
if (returning === false) {
await VersionModel.updateOne(query, versionData, options)
return null
}
return result
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
if (!doc) {
return null
}
transform({ adapter: this, data: doc, fields, operation: 'write' })
return doc
}

View File

@@ -4,7 +4,16 @@ import type { MongooseAdapter } from './index.js'
export const upsert: Upsert = async function upsert(
this: MongooseAdapter,
{ collection, data, locale, req, select, where },
{ collection, data, locale, req, returning, select, where },
) {
return this.updateOne({ collection, data, locale, options: { upsert: true }, req, select, where })
return this.updateOne({
collection,
data,
locale,
options: { upsert: true },
req,
returning,
select,
where,
})
}

View File

@@ -78,6 +78,7 @@ export const buildJoinAggregation = async ({
}
const {
count = false,
limit: limitJoin = join.field.defaultLimit ?? 10,
page,
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
@@ -121,6 +122,28 @@ export const buildJoinAggregation = async ({
const alias = `${as}.docs.${collectionSlug}`
aliases.push(alias)
const basePipeline = [
{
$addFields: {
relationTo: {
$literal: collectionSlug,
},
},
},
{
$match: {
$and: [
{
$expr: {
$eq: [`$${join.field.on}`, '$$root_id_'],
},
},
$match,
],
},
},
]
aggregate.push({
$lookup: {
as: alias,
@@ -129,25 +152,7 @@ export const buildJoinAggregation = async ({
root_id_: '$_id',
},
pipeline: [
{
$addFields: {
relationTo: {
$literal: collectionSlug,
},
},
},
{
$match: {
$and: [
{
$expr: {
$eq: [`$${join.field.on}`, '$$root_id_'],
},
},
$match,
],
},
},
...basePipeline,
{
$sort: {
[sortProperty]: sortDirection,
@@ -169,6 +174,24 @@ export const buildJoinAggregation = async ({
],
},
})
if (count) {
aggregate.push({
$lookup: {
as: `${as}.totalDocs.${alias}`,
from: adapter.collections[collectionSlug].collection.name,
let: {
root_id_: '$_id',
},
pipeline: [
...basePipeline,
{
$count: 'result',
},
],
},
})
}
}
aggregate.push({
@@ -179,6 +202,23 @@ export const buildJoinAggregation = async ({
},
})
if (count) {
aggregate.push({
$addFields: {
[`${as}.totalDocs`]: {
$add: aliases.map((alias) => ({
$ifNull: [
{
$first: `$${as}.totalDocs.${alias}.result`,
},
0,
],
})),
},
},
})
}
aggregate.push({
$set: {
[`${as}.docs`]: {
@@ -195,17 +235,17 @@ export const buildJoinAggregation = async ({
const sliceValue = page ? [(page - 1) * limitJoin, limitJoin] : [limitJoin]
aggregate.push({
$set: {
[`${as}.docs`]: {
$slice: [`$${as}.docs`, ...sliceValue],
$addFields: {
[`${as}.hasNextPage`]: {
$gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE],
},
},
})
aggregate.push({
$addFields: {
[`${as}.hasNextPage`]: {
$gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE],
$set: {
[`${as}.docs`]: {
$slice: [`$${as}.docs`, ...sliceValue],
},
},
})
@@ -222,6 +262,7 @@ export const buildJoinAggregation = async ({
}
const {
count,
limit: limitJoin = join.field.defaultLimit ?? 10,
page,
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
@@ -274,6 +315,31 @@ export const buildJoinAggregation = async ({
polymorphicSuffix = '.value'
}
const addTotalDocsAggregation = (as: string, foreignField: string) =>
aggregate.push(
{
$lookup: {
as: `${as}.totalDocs`,
foreignField,
from: adapter.collections[slug].collection.name,
localField: versions ? 'parent' : '_id',
pipeline: [
{
$match,
},
{
$count: 'result',
},
],
},
},
{
$addFields: {
[`${as}.totalDocs`]: { $ifNull: [{ $first: `$${as}.totalDocs.result` }, 0] },
},
},
)
if (adapter.payload.config.localization && locale === 'all') {
adapter.payload.config.localization.localeCodes.forEach((code) => {
const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${code}`
@@ -304,6 +370,7 @@ export const buildJoinAggregation = async ({
},
},
)
if (limitJoin > 0) {
aggregate.push({
$addFields: {
@@ -313,6 +380,10 @@ export const buildJoinAggregation = async ({
},
})
}
if (count) {
addTotalDocsAggregation(as, `${join.field.on}${code}${polymorphicSuffix}`)
}
})
} else {
const localeSuffix =
@@ -359,6 +430,11 @@ export const buildJoinAggregation = async ({
},
},
)
if (count) {
addTotalDocsAggregation(as, foreignField)
}
if (limitJoin > 0) {
aggregate.push({
$addFields: {

View File

@@ -1,20 +0,0 @@
const internalFields = ['__v']
export const sanitizeInternalFields = <T extends Record<string, unknown>>(incomingDoc: T): T =>
Object.entries(incomingDoc).reduce((newDoc, [key, val]): T => {
if (key === '_id') {
return {
...newDoc,
id: val,
}
}
if (internalFields.indexOf(key) > -1) {
return newDoc
}
return {
...newDoc,
[key]: val,
}
}, {} as T)

View File

@@ -1,165 +0,0 @@
import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload'
import { Types } from 'mongoose'
import { traverseFields } from 'payload'
import { fieldAffectsData, fieldShouldBeLocalized } from 'payload/shared'
type Args = {
config: SanitizedConfig
data: Record<string, unknown>
fields: Field[]
parentIsLocalized?: boolean
}
interface RelationObject {
relationTo: string
value: number | string
}
function isValidRelationObject(value: unknown): value is RelationObject {
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
}
const convertValue = ({
relatedCollection,
value,
}: {
relatedCollection: CollectionConfig
value: number | string
}): number | string | Types.ObjectId => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (customIDField) {
return value
}
try {
return new Types.ObjectId(value)
} catch {
return value
}
}
const sanitizeRelationship = ({ config, field, locale, ref, value }) => {
let relatedCollection: CollectionConfig | undefined
let result = value
const hasManyRelations = typeof field.relationTo !== 'string'
if (!hasManyRelations) {
relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
}
if (Array.isArray(value)) {
result = value.map((val) => {
// Handle has many
if (relatedCollection && val && (typeof val === 'string' || typeof val === 'number')) {
return convertValue({
relatedCollection,
value: val,
})
}
// Handle has many - polymorphic
if (isValidRelationObject(val)) {
const relatedCollectionForSingleValue = config.collections?.find(
({ slug }) => slug === val.relationTo,
)
if (relatedCollectionForSingleValue) {
return {
relationTo: val.relationTo,
value: convertValue({
relatedCollection: relatedCollectionForSingleValue,
value: val.value,
}),
}
}
}
return val
})
}
// Handle has one - polymorphic
if (isValidRelationObject(value)) {
relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo)
if (relatedCollection) {
result = {
relationTo: value.relationTo,
value: convertValue({ relatedCollection, value: value.value }),
}
}
}
// Handle has one
if (relatedCollection && value && (typeof value === 'string' || typeof value === 'number')) {
result = convertValue({
relatedCollection,
value,
})
}
if (locale) {
ref[locale] = result
} else {
ref[field.name] = result
}
}
export const sanitizeRelationshipIDs = ({
config,
data,
fields,
parentIsLocalized,
}: Args): Record<string, unknown> => {
const sanitize: TraverseFieldsCallback = ({ field, ref }) => {
if (!ref || typeof ref !== 'object') {
return
}
if (field.type === 'relationship' || field.type === 'upload') {
if (!ref[field.name]) {
return
}
// handle localized relationships
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
const locales = config.localization.locales
const fieldRef = ref[field.name]
if (typeof fieldRef !== 'object') {
return
}
for (const { code } of locales) {
const value = ref[field.name][code]
if (value) {
sanitizeRelationship({ config, field, locale: code, ref: fieldRef, value })
}
}
} else {
// handle non-localized relationships
sanitizeRelationship({
config,
field,
locale: undefined,
ref,
value: ref[field.name],
})
}
}
}
traverseFields({
callback: sanitize,
config,
fields,
fillEmpty: false,
parentIsLocalized,
ref: data,
})
return data
}

View File

@@ -2,7 +2,8 @@ import { flattenAllFields, type Field, type SanitizedConfig } from 'payload'
import { Types } from 'mongoose'
import { sanitizeRelationshipIDs } from './sanitizeRelationshipIDs.js'
import { transform } from './transform.js'
import type { MongooseAdapter } from '../index.js'
const flattenRelationshipValues = (obj: Record<string, any>, prefix = ''): Record<string, any> => {
return Object.keys(obj).reduce(
@@ -297,7 +298,7 @@ const relsData = {
},
}
describe('sanitizeRelationshipIDs', () => {
describe('transform', () => {
it('should sanitize relationships', () => {
const data = {
...relsData,
@@ -382,7 +383,18 @@ describe('sanitizeRelationshipIDs', () => {
}
const flattenValuesBefore = Object.values(flattenRelationshipValues(data))
sanitizeRelationshipIDs({ config, data, fields: config.collections[0].fields })
const mockAdapter = {
payload: {
config,
},
} as MongooseAdapter
transform({
adapter: mockAdapter,
operation: 'write',
data,
fields: config.collections[0].fields,
})
const flattenValuesAfter = Object.values(flattenRelationshipValues(data))
flattenValuesAfter.forEach((value, i) => {

View File

@@ -0,0 +1,347 @@
import type {
CollectionConfig,
DateField,
Field,
JoinField,
RelationshipField,
SanitizedConfig,
TraverseFieldsCallback,
UploadField,
} from 'payload'
import { Types } from 'mongoose'
import { traverseFields } from 'payload'
import { fieldAffectsData, fieldShouldBeLocalized } from 'payload/shared'
import type { MongooseAdapter } from '../index.js'
interface RelationObject {
relationTo: string
value: number | string
}
function isValidRelationObject(value: unknown): value is RelationObject {
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
}
const convertRelationshipValue = ({
operation,
relatedCollection,
validateRelationships,
value,
}: {
operation: Args['operation']
relatedCollection: CollectionConfig
validateRelationships?: boolean
value: unknown
}) => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (operation === 'read') {
if (value instanceof Types.ObjectId) {
return value.toHexString()
}
return value
}
if (customIDField) {
return value
}
if (typeof value === 'string') {
try {
return new Types.ObjectId(value)
} catch (e) {
if (validateRelationships) {
throw e
}
return value
}
}
return value
}
const sanitizeRelationship = ({
config,
field,
locale,
operation,
ref,
validateRelationships,
value,
}: {
config: SanitizedConfig
field: JoinField | RelationshipField | UploadField
locale?: string
operation: Args['operation']
ref: Record<string, unknown>
validateRelationships?: boolean
value?: unknown
}) => {
if (field.type === 'join') {
if (
operation === 'read' &&
value &&
typeof value === 'object' &&
'docs' in value &&
Array.isArray(value.docs)
) {
for (let i = 0; i < value.docs.length; i++) {
const item = value.docs[i]
if (item instanceof Types.ObjectId) {
value.docs[i] = item.toHexString()
} else if (Array.isArray(field.collection) && item) {
// Fields here for polymorphic joins cannot be determinted, JSON.parse needed
value.docs[i] = JSON.parse(JSON.stringify(value.docs[i]))
}
}
}
return value
}
let relatedCollection: CollectionConfig | undefined
let result = value
const hasManyRelations = typeof field.relationTo !== 'string'
if (!hasManyRelations) {
relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
}
if (Array.isArray(value)) {
result = value.map((val) => {
// Handle has many - polymorphic
if (isValidRelationObject(val)) {
const relatedCollectionForSingleValue = config.collections?.find(
({ slug }) => slug === val.relationTo,
)
if (relatedCollectionForSingleValue) {
return {
relationTo: val.relationTo,
value: convertRelationshipValue({
operation,
relatedCollection: relatedCollectionForSingleValue,
validateRelationships,
value: val.value,
}),
}
}
}
if (relatedCollection) {
return convertRelationshipValue({
operation,
relatedCollection,
validateRelationships,
value: val,
})
}
return val
})
}
// Handle has one - polymorphic
else if (isValidRelationObject(value)) {
relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo)
if (relatedCollection) {
result = {
relationTo: value.relationTo,
value: convertRelationshipValue({
operation,
relatedCollection,
validateRelationships,
value: value.value,
}),
}
}
}
// Handle has one
else if (relatedCollection) {
result = convertRelationshipValue({
operation,
relatedCollection,
validateRelationships,
value,
})
}
if (locale) {
ref[locale] = result
} else {
ref[field.name] = result
}
}
const sanitizeDate = ({
field,
locale,
ref,
value,
}: {
field: DateField
locale?: string
ref: Record<string, unknown>
value: unknown
}) => {
if (!value) {
return
}
if (value instanceof Date) {
value = value.toISOString()
}
if (locale) {
ref[locale] = value
} else {
ref[field.name] = value
}
}
type Args = {
/** instance of the adapter */
adapter: MongooseAdapter
/** data to transform, can be an array of documents or a single document */
data: Record<string, unknown> | Record<string, unknown>[]
/** fields accossiated with the data */
fields: Field[]
/** slug of the global, pass only when the operation is `write` */
globalSlug?: string
/**
* Type of the operation
* read - sanitizes ObjectIDs, Date to strings.
* write - sanitizes string relationships to ObjectIDs.
*/
operation: 'read' | 'write'
parentIsLocalized?: boolean
/**
* Throw errors on invalid relationships
* @default true
*/
validateRelationships?: boolean
}
export const transform = ({
adapter,
data,
fields,
globalSlug,
operation,
parentIsLocalized,
validateRelationships = true,
}: Args) => {
if (Array.isArray(data)) {
for (let i = 0; i < data.length; i++) {
transform({ adapter, data: data[i], fields, globalSlug, operation, validateRelationships })
}
return
}
const {
payload: { config },
} = adapter
if (operation === 'read') {
delete data['__v']
data.id = data._id
delete data['_id']
if (data.id instanceof Types.ObjectId) {
data.id = data.id.toHexString()
}
}
if (operation === 'write' && globalSlug) {
data.globalType = globalSlug
}
const sanitize: TraverseFieldsCallback = ({ field, ref }) => {
if (!ref || typeof ref !== 'object') {
return
}
if (field.type === 'date' && operation === 'read' && ref[field.name]) {
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
const fieldRef = ref[field.name]
if (!fieldRef || typeof fieldRef !== 'object') {
return
}
for (const locale of config.localization.localeCodes) {
sanitizeDate({
field,
ref: fieldRef,
value: fieldRef[locale],
})
}
} else {
sanitizeDate({
field,
ref: ref as Record<string, unknown>,
value: ref[field.name],
})
}
}
if (
field.type === 'relationship' ||
field.type === 'upload' ||
(operation === 'read' && field.type === 'join')
) {
if (!ref[field.name]) {
return
}
// handle localized relationships
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
const locales = config.localization.locales
const fieldRef = ref[field.name]
if (typeof fieldRef !== 'object') {
return
}
for (const { code } of locales) {
const value = ref[field.name][code]
if (value) {
sanitizeRelationship({
config,
field,
locale: code,
operation,
ref: fieldRef,
validateRelationships,
value,
})
}
}
} else {
// handle non-localized relationships
sanitizeRelationship({
config,
field,
locale: undefined,
operation,
ref: ref as Record<string, unknown>,
validateRelationships,
value: ref[field.name],
})
}
}
}
traverseFields({
callback: sanitize,
config,
fields,
fillEmpty: false,
parentIsLocalized,
ref: data,
})
}

View File

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

View File

@@ -1,8 +1,8 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Connect, Payload } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle'
import { drizzle } from 'drizzle-orm/node-postgres'
import { captureError, type Connect, type Payload } from 'payload'
import pg from 'pg'
import type { PostgresAdapter } from './types.js'
@@ -87,18 +87,16 @@ export const connect: Connect = async function connect(
await this.connect(options)
return
}
} else {
this.payload.logger.error({
err,
msg: `Error: cannot connect to Postgres. Details: ${err.message}`,
})
}
if (typeof this.rejectInitializing === 'function') {
this.rejectInitializing()
}
await captureError({
err,
msg: `Error: cannot connect to Postgres. Details: ${err.message}`,
payload: this.payload,
})
process.exit(1)
}

View File

@@ -33,6 +33,7 @@ import {
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
@@ -185,6 +186,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
upsert: updateOne,

View File

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

View File

@@ -1,9 +1,9 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Connect } from 'payload'
import { createClient } from '@libsql/client'
import { pushDevSchema } from '@payloadcms/drizzle'
import { drizzle } from 'drizzle-orm/libsql'
import { captureError, type Connect } from 'payload'
import type { SQLiteAdapter } from './types.js'
@@ -36,14 +36,10 @@ export const connect: Connect = async function connect(
}
}
} catch (err) {
this.payload.logger.error({ err, msg: `Error: cannot connect to SQLite: ${err.message}` })
if (typeof this.rejectInitializing === 'function') {
this.rejectInitializing()
}
await captureError({
err,
msg: `Error: cannot connect to SQLite: ${err.message}`,
payload: this.payload,
})
process.exit(1)
}

View File

@@ -50,10 +50,12 @@ const createConstraint = ({
const newAlias = `${pathSegments[0]}_alias_${pathSegments.length - 1}`
let formattedValue = value
let formattedOperator = operator
if (['contains', 'like'].includes(operator)) {
formattedOperator = 'like'
formattedValue = `%${value}%`
} else if (['not_like', 'notlike'].includes(operator)) {
formattedOperator = 'not like'
formattedValue = `%${value}%`
} else if (operator === 'equals') {
formattedOperator = '='
}
@@ -61,7 +63,7 @@ const createConstraint = ({
return `EXISTS (
SELECT 1
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
WHERE ${newAlias}.value ->> '${pathSegments[1]}' ${formattedOperator} '${formattedValue}'
WHERE COALESCE(${newAlias}.value ->> '${pathSegments[1]}', '') ${formattedOperator} '${formattedValue}'
)`
}

View File

@@ -34,10 +34,11 @@ import {
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
import { like } from 'drizzle-orm'
import { like, notLike } from 'drizzle-orm'
import { createDatabaseAdapter, defaultBeginTransaction } from 'payload'
import { fileURLToPath } from 'url'
@@ -81,6 +82,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
...operatorMap,
contains: like,
like,
not_like: notLike,
} as unknown as Operators
return createDatabaseAdapter<SQLiteAdapter>({
@@ -119,6 +121,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
tableNameMap: new Map<string, string>(),
tables: {},
transactionOptions: args.transactionOptions || undefined,
updateMany,
versionsSuffix: args.versionsSuffix || '_v',
// DatabaseAdapter

View File

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

View File

@@ -1,9 +1,9 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Connect } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle'
import { sql, VercelPool } from '@vercel/postgres'
import { drizzle } from 'drizzle-orm/node-postgres'
import { captureError, type Connect } from 'payload'
import pg from 'pg'
import type { VercelPostgresAdapter } from './types.js'
@@ -72,18 +72,16 @@ export const connect: Connect = async function connect(
await this.connect(options)
return
}
} else {
this.payload.logger.error({
err,
msg: `Error: cannot connect to Postgres. Details: ${err.message}`,
})
}
if (typeof this.rejectInitializing === 'function') {
this.rejectInitializing()
}
await captureError({
err,
msg: `Error: cannot connect to Postgres. Details: ${err.message}`,
payload: this.payload,
})
process.exit(1)
}

View File

@@ -33,6 +33,7 @@ import {
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
@@ -186,6 +187,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateMany,
updateOne,
updateVersion,
upsert: updateOne,

View File

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

View File

@@ -9,7 +9,7 @@ import { getTransaction } from './utilities/getTransaction.js'
export const create: Create = async function create(
this: DrizzleAdapter,
{ collection: collectionSlug, data, req, select },
{ collection: collectionSlug, data, req, select, returning },
) {
const db = await getTransaction(this, req)
const collection = this.payload.collections[collectionSlug].config
@@ -25,7 +25,12 @@ export const create: Create = async function create(
req,
select,
tableName,
ignoreResult: returning === false,
})
if (returning === false) {
return null
}
return result
}

View File

@@ -9,7 +9,7 @@ import { getTransaction } from './utilities/getTransaction.js'
export async function createGlobal<T extends Record<string, unknown>>(
this: DrizzleAdapter,
{ slug, data, req }: CreateGlobalArgs,
{ slug, data, req, returning }: CreateGlobalArgs,
): Promise<T> {
const db = await getTransaction(this, req)
const globalConfig = this.payload.globals.config.find((config) => config.slug === slug)
@@ -26,8 +26,13 @@ export async function createGlobal<T extends Record<string, unknown>>(
operation: 'create',
req,
tableName,
ignoreResult: returning === false,
})
if (returning === false) {
return null
}
result.globalType = slug
return result

View File

@@ -21,6 +21,7 @@ export async function createGlobalVersion<T extends TypeWithID>(
snapshot,
updatedAt,
versionData,
returning,
}: CreateGlobalVersionArgs,
) {
const db = await getTransaction(this, req)
@@ -45,6 +46,7 @@ export async function createGlobalVersion<T extends TypeWithID>(
req,
select,
tableName,
ignoreResult: returning === false ? 'idOnly' : false,
})
const table = this.tables[tableName]
@@ -59,5 +61,9 @@ export async function createGlobalVersion<T extends TypeWithID>(
})
}
if (returning === false) {
return null
}
return result
}

View File

@@ -22,6 +22,7 @@ export async function createVersion<T extends TypeWithID>(
snapshot,
updatedAt,
versionData,
returning,
}: CreateVersionArgs<T>,
) {
const db = await getTransaction(this, req)
@@ -72,5 +73,9 @@ export async function createVersion<T extends TypeWithID>(
})
}
if (returning === false) {
return null
}
return result
}

View File

@@ -13,7 +13,7 @@ import { getTransaction } from './utilities/getTransaction.js'
export const deleteOne: DeleteOne = async function deleteOne(
this: DrizzleAdapter,
{ collection: collectionSlug, req, select, where: whereArg },
{ collection: collectionSlug, req, select, where: whereArg, returning },
) {
const db = await getTransaction(this, req)
const collection = this.payload.collections[collectionSlug].config
@@ -59,13 +59,16 @@ export const deleteOne: DeleteOne = async function deleteOne(
docToDelete = await db.query[tableName].findFirst(findManyArgs)
}
const result = transform({
adapter: this,
config: this.payload.config,
data: docToDelete,
fields: collection.flattenedFields,
joinQuery: false,
})
const result =
returning === false
? null
: transform({
adapter: this,
config: this.payload.config,
data: docToDelete,
fields: collection.flattenedFields,
joinQuery: false,
})
await this.deleteWhere({
db,

View File

@@ -2,7 +2,7 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { SQLiteSelectBase } from 'drizzle-orm/sqlite-core'
import type { FlattenedField, JoinQuery, SelectMode, SelectType, Where } from 'payload'
import { and, asc, desc, eq, or, sql } from 'drizzle-orm'
import { and, asc, count, desc, eq, or, sql } from 'drizzle-orm'
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
@@ -386,6 +386,7 @@ export const traverseFields = ({
}
const {
count: shouldCount = false,
limit: limitArg = field.defaultLimit ?? 10,
page,
sort = field.defaultSort,
@@ -480,6 +481,13 @@ export const traverseFields = ({
sqlWhere = and(sqlWhere, buildSQLWhere(where, subQueryAlias))
}
if (shouldCount) {
currentArgs.extras[`${columnName}_count`] = sql`${db
.select({ count: count() })
.from(sql`${currentQuery.as(subQueryAlias)}`)
.where(sqlWhere)}`.as(`${columnName}_count`)
}
currentQuery = currentQuery.orderBy(sortOrder(sql`"sortPath"`)) as SQLSelect
if (page && limit !== 0) {
@@ -611,6 +619,20 @@ export const traverseFields = ({
.orderBy(() => orderBy.map(({ column, order }) => order(column))),
}).as(subQueryAlias)
if (shouldCount) {
currentArgs.extras[`${columnName}_count`] = sql`${db
.select({
count: count(),
})
.from(
sql`${db
.select(selectFields as any)
.from(newAliasTable)
.where(subQueryWhere)
.as(`${subQueryAlias}_count_subquery`)}`,
)}`.as(`${subQueryAlias}_count`)
}
currentArgs.extras[columnName] = sql`${db
.select({
result: jsonAggBuildObject(adapter, {

View File

@@ -31,9 +31,10 @@ export { buildRawSchema } from './schema/buildRawSchema.js'
export { beginTransaction } from './transactions/beginTransaction.js'
export { commitTransaction } from './transactions/commitTransaction.js'
export { rollbackTransaction } from './transactions/rollbackTransaction.js'
export { updateOne } from './update.js'
export { updateGlobal } from './updateGlobal.js'
export { updateGlobalVersion } from './updateGlobalVersion.js'
export { updateMany } from './updateMany.js'
export { updateOne } from './updateOne.js'
export { updateVersion } from './updateVersion.js'
export { upsertRow } from './upsertRow/index.js'
export { buildCreateMigration } from './utilities/buildCreateMigration.js'

View File

@@ -1,7 +1,6 @@
import type { Payload } from 'payload'
import {
captureError,
commitTransaction,
createLocalReq,
initTransaction,
@@ -107,12 +106,10 @@ async function runMigrationFile(payload: Payload, migration: Migration, batch: n
await commitTransaction(req)
} catch (err: unknown) {
await killTransaction(req)
await captureError({
payload.logger.error({
err,
msg: parseError(err, `Error running migration ${migration.name}`),
req,
})
process.exit(1)
}
}

View File

@@ -1,5 +1,4 @@
import {
captureError,
commitTransaction,
createLocalReq,
getMigrations,
@@ -64,10 +63,9 @@ export async function migrateDown(this: DrizzleAdapter): Promise<void> {
} catch (err: unknown) {
await killTransaction(req)
await captureError({
payload.logger.error({
err,
msg: parseError(err, `Error migrating down ${migrationFile.name}. Rolling back.`),
req,
})
process.exit(1)
}

View File

@@ -1,5 +1,4 @@
import {
captureError,
commitTransaction,
createLocalReq,
initTransaction,
@@ -80,10 +79,9 @@ export async function migrateFresh(
payload.logger.info({ msg: `Migrated: ${migration.name} (${Date.now() - start}ms)` })
} catch (err: unknown) {
await killTransaction(req)
await captureError({
payload.logger.error({
err,
msg: parseError(err, `Error running migration ${migration.name}. Rolling back`),
req,
})
process.exit(1)
}

View File

@@ -1,5 +1,4 @@
import {
captureError,
commitTransaction,
createLocalReq,
getMigrations,
@@ -70,10 +69,9 @@ export async function migrateRefresh(this: DrizzleAdapter) {
await commitTransaction(req)
} catch (err: unknown) {
await killTransaction(req)
await captureError({
payload.logger.error({
err,
msg: parseError(err, `Error running migration ${migration.name}. Rolling back.`),
req,
})
process.exit(1)
}
@@ -99,10 +97,9 @@ export async function migrateRefresh(this: DrizzleAdapter) {
payload.logger.info({ msg: `Migrated: ${migration.name} (${Date.now() - start}ms)` })
} catch (err: unknown) {
await killTransaction(req)
await captureError({
payload.logger.error({
err,
msg: parseError(err, `Error running migration ${migration.name}. Rolling back.`),
req,
})
process.exit(1)
}

View File

@@ -1,5 +1,4 @@
import {
captureError,
commitTransaction,
createLocalReq,
getMigrations,
@@ -64,7 +63,10 @@ export async function migrateReset(this: DrizzleAdapter): Promise<void> {
}
await killTransaction(req)
await captureError({ err, msg, req })
payload.logger.error({
err,
msg,
})
process.exit(1)
}
}
@@ -83,7 +85,7 @@ export async function migrateReset(this: DrizzleAdapter): Promise<void> {
},
})
} catch (err: unknown) {
await captureError({ err, msg: 'Error deleting dev migration', req })
payload.logger.error({ err, msg: 'Error deleting dev migration' })
}
}
}

View File

@@ -1,7 +1,5 @@
import type { ClientConfig } from 'pg'
import { captureError } from 'payload'
import type { BasePostgresAdapter } from './types.js'
const setConnectionStringDatabase = ({
@@ -90,10 +88,9 @@ export const createDatabase = async function (this: BasePostgresAdapter, args: A
await createdDatabaseClient.query(`CREATE SCHEMA ${schemaName}`)
this.payload.logger.info(`Created schema "${dbName}.${schemaName}"`)
} catch (err) {
await captureError({
this.payload.logger.error({
err,
msg: `Error: failed to create schema "${dbName}.${schemaName}". Details: ${err.message}`,
payload: this.payload,
})
} finally {
await createdDatabaseClient.end()
@@ -102,10 +99,9 @@ export const createDatabase = async function (this: BasePostgresAdapter, args: A
return true
} catch (err) {
await captureError({
this.payload.logger.error({
err,
msg: `Error: failed to create database ${dbName}. Details: ${err.message}`,
payload: this.payload,
})
return false

View File

@@ -1,5 +1,3 @@
import { captureError } from 'payload'
import type { BasePostgresAdapter } from './types.js'
export const createExtensions = async function (this: BasePostgresAdapter): Promise<void> {
@@ -8,11 +6,7 @@ export const createExtensions = async function (this: BasePostgresAdapter): Prom
try {
await this.drizzle.execute(`CREATE EXTENSION IF NOT EXISTS "${extension}"`)
} catch (err) {
await captureError({
err,
msg: `Failed to create extension ${extension}`,
payload: this.payload,
})
this.payload.logger.error({ err, msg: `Failed to create extension ${extension}` })
}
}
}

View File

@@ -7,12 +7,13 @@ const operatorMap: Record<string, string> = {
like: 'like_regex',
not_equals: '!=',
not_in: 'in',
not_like: '!like_regex',
}
const sanitizeValue = (value: unknown, operator?: string) => {
if (typeof value === 'string') {
// ignore casing with like
return `"${operator === 'like' ? '(?i)' : ''}${value}"`
// ignore casing with like or not_like
return `"${['like', 'not_like'].includes(operator) ? '(?i)' : ''}${value}"`
}
return value as string
@@ -35,6 +36,10 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
})
} else if (operator === 'exists') {
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')`
} else if (['not_like'].includes(operator)) {
const mappedOperator = operatorMap[operator]
sql = `NOT jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')`
} else {
sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
}

View File

@@ -11,6 +11,7 @@ import {
lt,
lte,
ne,
notIlike,
notInArray,
or,
type SQL,
@@ -31,6 +32,7 @@ type OperatorKeys =
| 'like'
| 'not_equals'
| 'not_in'
| 'not_like'
| 'or'
export type Operators = Record<OperatorKeys, (column: Column, value: SQLWrapper | unknown) => SQL>
@@ -48,6 +50,7 @@ export const operatorMap: Operators = {
less_than_equal: lte,
like: ilike,
not_equals: ne,
not_like: notIlike,
// TODO: support this
// all: all,
not_in: notInArray,

View File

@@ -161,6 +161,7 @@ export function parseParams({
like: { operator: 'like', wildcard: '%' },
not_equals: { operator: '<>', wildcard: '' },
not_in: { operator: 'not in', wildcard: '' },
not_like: { operator: 'not like', wildcard: '%' },
}
let formattedValue = val
@@ -175,11 +176,15 @@ export function parseParams({
formattedValue = ''
}
constraints.push(
sql.raw(
`${table[columnName].name}${jsonQuery} ${operatorKeys[operator].operator} ${formattedValue}`,
),
)
let jsonQuerySelector = `${table[columnName].name}${jsonQuery}`
if (adapter.name === 'sqlite' && operator === 'not_like') {
jsonQuerySelector = `COALESCE(${table[columnName].name}${jsonQuery}, '')`
}
const rawSQLQuery = `${jsonQuerySelector} ${operatorKeys[operator].operator} ${formattedValue}`
constraints.push(sql.raw(rawSQLQuery))
break
}

View File

@@ -1,4 +1,5 @@
import { type BeginTransaction, captureError } from 'payload'
import type { BeginTransaction } from 'payload'
import { v4 as uuid } from 'uuid'
import type { DrizzleAdapter, DrizzleTransaction } from '../types.js'
@@ -57,11 +58,7 @@ export const beginTransaction: BeginTransaction = async function beginTransactio
resolve,
}
} catch (err) {
await captureError({
err,
msg: `Error: cannot begin transaction: ${err.message}`,
payload: this.payload,
})
this.payload.logger.error({ err, msg: `Error: cannot begin transaction: ${err.message}` })
process.exit(1)
}

View File

@@ -1,6 +1,7 @@
import type { FlattenedBlock, FlattenedField, JoinQuery, SanitizedConfig } from 'payload'
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from '../../types.js'
import type { BlocksMap } from '../../utilities/createBlocksMap.js'
@@ -398,7 +399,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
}
if (field.type === 'join') {
const { limit = field.defaultLimit ?? 10 } =
const { count, limit = field.defaultLimit ?? 10 } =
joinQuery?.[`${fieldPrefix.replaceAll('_', '.')}${field.name}`] || {}
// raw hasMany results from SQLite
@@ -407,8 +408,8 @@ export const traverseFields = <T extends Record<string, unknown>>({
}
let fieldResult:
| { docs: unknown[]; hasNextPage: boolean }
| Record<string, { docs: unknown[]; hasNextPage: boolean }>
| { docs: unknown[]; hasNextPage: boolean; totalDocs?: number }
| Record<string, { docs: unknown[]; hasNextPage: boolean; totalDocs?: number }>
if (Array.isArray(fieldData)) {
if (isLocalized && adapter.payload.config.localization) {
fieldResult = fieldData.reduce(
@@ -449,6 +450,17 @@ export const traverseFields = <T extends Record<string, unknown>>({
}
}
if (count) {
const countPath = `${fieldName}_count`
if (typeof table[countPath] !== 'undefined') {
let value = Number(table[countPath])
if (Number.isNaN(value)) {
value = 0
}
fieldResult.totalDocs = value
}
}
result[field.name] = fieldResult
return result
}
@@ -607,6 +619,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
deletions,
fieldPrefix: groupFieldPrefix,
fields: field.flattenedFields,
joinQuery,
numbers,
parentIsLocalized: parentIsLocalized || field.localized,
path: `${sanitizedPath}${field.name}`,

View File

@@ -9,7 +9,7 @@ import { getTransaction } from './utilities/getTransaction.js'
export async function updateGlobal<T extends Record<string, unknown>>(
this: DrizzleAdapter,
{ slug, data, req, select }: UpdateGlobalArgs,
{ slug, data, req, select, returning }: UpdateGlobalArgs,
): Promise<T> {
const db = await getTransaction(this, req)
const globalConfig = this.payload.globals.config.find((config) => config.slug === slug)
@@ -26,8 +26,13 @@ export async function updateGlobal<T extends Record<string, unknown>>(
req,
select,
tableName,
ignoreResult: returning === false,
})
if (returning === false) {
return null
}
result.globalType = slug
return result

View File

@@ -16,7 +16,16 @@ import { getTransaction } from './utilities/getTransaction.js'
export async function updateGlobalVersion<T extends TypeWithID>(
this: DrizzleAdapter,
{ id, global, locale, req, select, versionData, where: whereArg }: UpdateGlobalVersionArgs<T>,
{
id,
global,
locale,
req,
select,
versionData,
where: whereArg,
returning,
}: UpdateGlobalVersionArgs<T>,
) {
const db = await getTransaction(this, req)
const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find(
@@ -49,7 +58,12 @@ export async function updateGlobalVersion<T extends TypeWithID>(
select,
tableName,
where,
ignoreResult: returning === false,
})
if (returning === false) {
return null
}
return result
}

View File

@@ -0,0 +1,97 @@
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { UpdateMany } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { upsertRow } from './upsertRow/index.js'
import { getTransaction } from './utilities/getTransaction.js'
export const updateMany: UpdateMany = async function updateMany(
this: DrizzleAdapter,
{
collection: collectionSlug,
data,
joins: joinQuery,
locale,
req,
returning,
select,
where: whereToUse,
},
) {
const db = await getTransaction(this, req)
const collection = this.payload.collections[collectionSlug].config
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))
const { joins, selectFields, where } = buildQuery({
adapter: this,
fields: collection.flattenedFields,
locale,
tableName,
where: whereToUse,
})
let idsToUpdate: (number | string)[] = []
const selectDistinctResult = await selectDistinct({
adapter: this,
db,
joins,
selectFields,
tableName,
where,
})
if (selectDistinctResult?.[0]?.id) {
idsToUpdate = selectDistinctResult?.map((doc) => doc.id)
// If id wasn't passed but `where` without any joins, retrieve it with findFirst
} else if (whereToUse && !joins.length) {
const _db = db as LibSQLDatabase
const table = this.tables[tableName]
const docsToUpdate = await _db
.select({
id: table.id,
})
.from(table)
.where(where)
idsToUpdate = docsToUpdate?.map((doc) => doc.id)
}
if (!idsToUpdate.length) {
return []
}
const results = []
// TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows.
for (const idToUpdate of idsToUpdate) {
const result = await upsertRow({
id: idToUpdate,
adapter: this,
data,
db,
fields: collection.flattenedFields,
ignoreResult: returning === false,
joinQuery,
operation: 'update',
req,
select,
tableName,
})
results.push(result)
}
if (returning === false) {
return null
}
return results
}

View File

@@ -1,10 +1,10 @@
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { UpdateOne } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import { buildFindManyArgs } from './find/buildFindManyArgs.js'
import buildQuery from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { upsertRow } from './upsertRow/index.js'
@@ -12,7 +12,17 @@ import { getTransaction } from './utilities/getTransaction.js'
export const updateOne: UpdateOne = async function updateOne(
this: DrizzleAdapter,
{ id, collection: collectionSlug, data, joins: joinQuery, locale, req, select, where: whereArg },
{
id,
collection: collectionSlug,
data,
joins: joinQuery,
locale,
req,
select,
where: whereArg,
returning,
},
) {
const db = await getTransaction(this, req)
const collection = this.payload.collections[collectionSlug].config
@@ -28,6 +38,7 @@ export const updateOne: UpdateOne = async function updateOne(
where: whereToUse,
})
// selectDistinct will only return if there are joins
const selectDistinctResult = await selectDistinct({
adapter: this,
chainedMethods: [{ args: [1], method: 'limit' }],
@@ -40,22 +51,18 @@ export const updateOne: UpdateOne = async function updateOne(
if (selectDistinctResult?.[0]?.id) {
idToUpdate = selectDistinctResult?.[0]?.id
// If id wasn't passed but `where` without any joins, retrieve it with findFirst
} else if (whereArg && !joins.length) {
const findManyArgs = buildFindManyArgs({
adapter: this,
depth: 0,
fields: collection.flattenedFields,
joinQuery: false,
select: {},
tableName,
})
const table = this.tables[tableName]
findManyArgs.where = where
const docToUpdate = await db.query[tableName].findFirst(findManyArgs)
idToUpdate = docToUpdate?.id
const docsToUpdate = await (db as LibSQLDatabase)
.select({
id: table.id,
})
.from(table)
.where(where)
.limit(1)
idToUpdate = docsToUpdate?.[0]?.id
}
const result = await upsertRow({
@@ -69,7 +76,12 @@ export const updateOne: UpdateOne = async function updateOne(
req,
select,
tableName,
ignoreResult: returning === false,
})
if (returning === false) {
return null
}
return result
}

View File

@@ -16,7 +16,16 @@ import { getTransaction } from './utilities/getTransaction.js'
export async function updateVersion<T extends TypeWithID>(
this: DrizzleAdapter,
{ id, collection, locale, req, select, versionData, where: whereArg }: UpdateVersionArgs<T>,
{
id,
collection,
locale,
req,
select,
versionData,
where: whereArg,
returning,
}: UpdateVersionArgs<T>,
) {
const db = await getTransaction(this, req)
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
@@ -47,7 +56,12 @@ export async function updateVersion<T extends TypeWithID>(
select,
tableName,
where,
ignoreResult: returning === false,
})
if (returning === false) {
return null
}
return result
}

View File

@@ -428,6 +428,10 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
}
}
if (ignoreResult === 'idOnly') {
return { id: insertedRow.id } as T
}
if (ignoreResult) {
return data as T
}

View File

@@ -12,7 +12,7 @@ type BaseArgs = {
* When true, skips reading the data back from the database and returns the input data
* @default false
*/
ignoreResult?: boolean
ignoreResult?: boolean | 'idOnly'
joinQuery?: JoinQuery
path?: string
req?: Partial<PayloadRequest>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.23.0",
"version": "3.25.0",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {
@@ -45,8 +45,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"payload": "workspace:*"
},
"peerDependencies": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.23.0",
"version": "3.25.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -96,7 +96,6 @@
"qs-esm": "7.0.2",
"react-diff-viewer-continued": "4.0.4",
"sass": "1.77.4",
"sonner": "^1.7.0",
"uuid": "10.0.0"
},
"devDependencies": {
@@ -108,8 +107,8 @@
"@next/eslint-plugin-next": "15.1.5",
"@payloadcms/eslint-config": "workspace:*",
"@types/busboy": "1.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/uuid": "10.0.0",
"babel-plugin-react-compiler": "19.0.0-beta-714736e-20250131",
"esbuild": "0.24.2",

View File

@@ -1,16 +1,20 @@
import type { NavPreferences, Payload, User } from 'payload'
import type { DefaultDocumentIDType, NavPreferences, Payload, User } from 'payload'
import { cache } from 'react'
export const getNavPrefs = cache(
async ({ payload, user }: { payload: Payload; user: User }): Promise<NavPreferences> =>
user
async (
payload: Payload,
userID: DefaultDocumentIDType,
userSlug: string,
): Promise<NavPreferences> => {
return userSlug
? await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
user,
pagination: false,
where: {
and: [
{
@@ -20,17 +24,18 @@ export const getNavPrefs = cache(
},
{
'user.relationTo': {
equals: user.collection,
equals: userSlug,
},
},
{
'user.value': {
equals: user.id,
equals: userID,
},
},
],
},
})
?.then((res) => res?.docs?.[0]?.value)
: null,
: null
},
)

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