Compare commits

...

51 Commits

Author SHA1 Message Date
Jessica Chowdhury
6ef6f2e55c fix(ui): updates useField to read initializing from useForm 2025-05-08 13:14:29 +01:00
Rot4tion
18693775e4 templates: fix Media component failing when setting a custom serverURL (#12214)
### What?
Fixes #12171

### Why?
Previously, the ImageMedia component was not properly handling URL
formatting when a serverURL was configured in Payload. This caused
images to fail to load when using a custom serverURL. By extracting the
URL handling logic into a separate utility function, we ensure
consistent URL processing across both image and video components.

### How?
1. Created a new utility function getMediaUrl in
`src/utilities/getMediaUrl.ts` that:
   - Properly checks for HTTP/HTTPS protocols
   - Handles null or undefined URL values
   - Supports cache tags to prevent caching issues
   - Uses `getClientSideURL()` for relative paths
2. Updated the ImageMedia component to use this utility function instead
of inline URL processing logic
3. Updated the VideoMedia component to also use the same utility
function for consistency
2025-05-07 15:45:12 -07:00
Tobias Odendahl
b3cac753d6 feat(ui): display the actual error message on unpublish if available (#11898)
### What?
If an error occurs while unpublishing a document in the edit view UI,
the toast which shows the error message now displays the actual message
which is sent from the server, if available.

### Why?
Only a generic error message was shown if an unpublish operation failed.
Some errors might be solvable by the user, so that there is value in
showing the actual, actionable error message instead of a generic one.

### How?
The server response is parsed for error message if an unpublish
operation fails and displayed in the toast, instead of the generic error
message.


![image](https://github.com/user-attachments/assets/774d68c6-b36b-4447-93a0-b437845694a9)
2025-05-06 17:27:05 -07:00
Paul
05ae957cd5 docs: add pagination and limit: 0 information in pagination for API docs (#12243)
Fixes https://github.com/payloadcms/payload/issues/12140
2025-05-05 23:17:04 +03:00
Sasha
800c424777 feat(storage-s3): presigned URLs for file downloads (#12307)
Adds pre-signed URLs support file downloads with the S3 adapter. Can be
enabled per-collection:
```ts
s3Storage({
  collections: {
    media: { signedDownloads: true }, // or { signedDownloads: { expiresIn: 3600 }} for custom expiresIn (default 7200)
  },
  bucket: process.env.S3_BUCKET,
  config: {
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY_ID,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
    },
    endpoint: process.env.S3_ENDPOINT,
    forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
    region: process.env.S3_REGION,
  },
}),
```

The main use case is when you care about the Payload access control (so
you don't want to use `disablePayloadAccessControl: true` but you don't
want your files to be served through Payload (which can affect
performance with large videos for example).
This feature instead generates a signed URL (after verifying the access
control) and redirects you directly to the S3 provider.

This is an addition to https://github.com/payloadcms/payload/pull/11382
which added pre-signed URLs for file uploads.
2025-05-05 23:16:14 +03:00
Elliot DeNolf
9a6bb44e50 chore(release): v3.37.0 [skip ci] 2025-05-05 15:12:34 -04:00
Ruslan
38186346f7 fix(ui): unable to search for nested fields in WhereBuilder field selection (#11986)
### What?
Extract text from the React node label in WhereBuilder

### Why?
If you have a nested field in filter options, the label would show
correctly, but the search will not work

### How
By adding an `extractTextFromReactNode` function that gets text out of
React.node label

### Code setup:
```
{
      type: "collapsible",
      label: "Meta",
      fields: [
        {
          name: 'media',
          type: 'relationship',
          relationTo: 'media',
          label: 'Ferrari',
          filterOptions: () => {
            return {
              id: { in: ['67efdbc872ca925bc2868933'] },
            }
          }
        },
        {
          name: 'media2',
          type: 'relationship',
          relationTo: 'media',
          label: 'Williams',
          filterOptions: () => {
            return {
              id: { in: ['67efdbc272ca925bc286891c'] },
            }
          }
        },
      ],
    },
    
 ```
  
### Before:

https://github.com/user-attachments/assets/25d4b3a2-6ac0-476b-973e-575238e916c4

  
 ### After:

https://github.com/user-attachments/assets/92346a6c-b2d1-4e08-b1e4-9ac1484f9ef3

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-05-05 13:09:26 -04:00
Anyu Jiang
a6d76d6058 fix(plugin-multi-tenant): make tenant selector respect order if orderable enabled for tenant collection (#12314)
### What?
Tenant Selector doesn’t honor the custom order when ‘orderable’ is
enabled for Tenant collection
### Why?
Currently, it uses "useAsTitle" to sort. In some use cases, for example,
when a user manages multiple tenants that have an inherent priority
(such as usage frequency), sorting purely by the useAsTitle isn’t very
practical.
### How?
Get "orderable" config from the tenant collection's config, if it has
"orderable" set as true, it will use _order to sort. If not, it will use
"useAsTitle" to sort as default.

Fixes #12246


![image](https://github.com/user-attachments/assets/b5c4ad5e-3503-4789-91f6-a7aafb326e32)
2025-05-05 13:01:55 -04:00
Florian Beeres
0d10f436cc fix(plugin-cloud-storage): missing 'prefix' in cloud storage plugin (#11970)
## Fix
We were able to narrow it down to this call
816fb28f55/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts (L26-L41)

Adding `draft: true` fixes the issue. It seems that the `prefix` can
only be found in a draft, and without `draft: true` those drafts aren't
searched.

### Issue reproduction

In the community folder, enable versioning for the media collection and
install the `s3storage` plugin (see Git patch). I use `minio` to have a
local S3 compatible backend and then I run the app with:
`AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin
START_MEMORY_DB=true pnpm dev _community`.

Next, open the media collection and create a new entry. Then open that
entry, remove the file it currently has, and upload a new file. Save as
draft.

Now the media can no longer be accessed and the thumbnails are broken.

If you make an edit but save it by publishing the issue goes away. I
also couldn't reproduce this by adding a text field, changing that, and
saving the document as draft.

```diff
diff --git test/_community/collections/Media/index.ts test/_community/collections/Media/index.ts
index bb5edd0349..689423053c 100644
--- test/_community/collections/Media/index.ts
+++ test/_community/collections/Media/index.ts
@@ -9,6 +9,9 @@ export const MediaCollection: CollectionConfig = {
     read: () => true,
   },
   fields: [],
+  versions: {
+    drafts: true,
+  },
   upload: {
     crop: true,
     focalPoint: true,
diff --git test/_community/config.ts test/_community/config.ts
index ee1aee6e46..c81ec5f933 100644
--- test/_community/config.ts
+++ test/_community/config.ts
@@ -7,6 +7,7 @@ import { devUser } from '../credentials.js'
 import { MediaCollection } from './collections/Media/index.js'
 import { PostsCollection, postsSlug } from './collections/Posts/index.js'
 import { MenuGlobal } from './globals/Menu/index.js'
+import { s3Storage } from '@payloadcms/storage-s3'
 
 const filename = fileURLToPath(import.meta.url)
 const dirname = path.dirname(filename)
@@ -24,6 +25,21 @@ export default buildConfigWithDefaults({
     // ...add more globals here
     MenuGlobal,
   ],
+  plugins: [
+    s3Storage({
+      enabled: true,
+      bucket: 'amboss',
+      config: {
+        region: 'eu-west-1',
+        endpoint: 'http://localhost:9000',
+      },
+      collections: {
+        media: {
+          prefix: 'media',
+        },
+      },
+    }),
+  ],
   onInit: async (payload) => {
     await payload.create({
       collection: 'users',

```

## Screen recording

https://github.com/user-attachments/assets/b13be4a3-e858-427a-8bfa-6592b87748ee
2025-05-05 10:24:08 -04:00
James Mikrut
dcd4e37ccc feat: exports additional login helper utils (#12309)
Exports a few utilities that are used internally to the login operation,
but could be helpful for others building plugins.

Specifically:

- `isUserLocked` - a check to ensure that a given user is not locked due
to too many invalid attempts
- `checkLoginPermissions` - checks to see that the user is not locked as
well as that it is properly verified, if applicable
- `jwtSign` - Payload's internal JWT signing approach
- `getFieldsToSign` - reduce down a document's fields for JWT creation
based on collection config settings
- `incrementLoginAttempts` / `resetLoginAttempts` - utilities to handle
both failed and successful login attempts
- `UnverifiedEmail` - an error that could be thrown if attempting to log
in to an account without prior successful email verification
2025-05-05 10:23:01 -04:00
Ruslan
446938b9cb feat(ui): update RelationshipFilter if only filterOptions are changed (#11985)
### What?
Extends trigger of a reload of the fields for RelationshipFilter to
include `filterOptions`.

### Why?
If you have two or more relationship fields that have a relation to the
same collection, the options of the filter will not update.

### How
By extending dependencies of `useEffect`

### Code setup:
```
{
    name: 'media',
    type: 'relationship',
    relationTo: 'media',
    filterOptions: () => {
      return {
        id: { in: ['67efaee24648d01dffceecf9'] },
      }
    }
  },
  {
    name: 'media2',
    type: 'relationship',
    relationTo: 'media',
    filterOptions: () => {
      return {
        id: { in: ['67efafb04648d01dffceed75'] },
      }
    }
  },
  ```
  
  ### Before:

https://github.com/user-attachments/assets/bdc5135b-3afa-48df-98fe-6a9153dd7710


  
  
 ### After:

https://github.com/user-attachments/assets/d71a7558-6413-4c97-9b0b-678cf3b011d0




-->
2025-05-05 10:14:27 -04:00
Tobias Odendahl
292b462f34 feat(ui): add document link to drawer (#12036)
### What?
Adds an option to open the current document in a new tab when opened in
a drawer.

### Why?
There is currently no direct way to open a document when opened in a
drawer. However, sometimes editors want to edit one or multiple
documents from relationships independently of the current edit view and
need an easy option to open these separately.

### How?
Converts the document id to a link if in drawer context.


![image](https://github.com/user-attachments/assets/e448328f-f685-49b8-95c5-bd5d6aa60e35)

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-05-05 10:09:26 -04:00
Sasha
2628b43639 fix(db-postgres): start transaction in v2-v3 migration only after drizzle prompts to avoid timeout (#12302)
When running the v2-v3 migration you might receive prompts for renaming
columns. Since we start a transaction before, you might end up with a
fail if you don't answer within your transaction session period timeout.
This moves the `getTransaction` call after prompts were answered, since
we don't have a reason to start it earlier.
2025-05-05 09:20:30 -04:00
Sasha
3fb81ef43b fix(graphql): nextPage and prevPage are non nullable even though they can be null sometimes (#12201)
This PR introduced https://github.com/payloadcms/payload/pull/11952
improvement for graphql schema with making fields of the `Paginated<T>`
interface non-nullable.

However, there are a few special ones - `nextPage` and `prevPage`. They
can be `null` when:
The result returned 0 docs.
The result returned `x` docs, but in the DB we don't have `x+1` doc.
Thus, `nextPage` will be `null`. The result will have `nextPage: null`.
Finally, when we query 1st page, `prevPage` is `null` as well.

<img width="873" alt="image"
src="https://github.com/user-attachments/assets/04d04b13-ac26-4fc1-b421-b5f86efc9b65"
/>
2025-05-05 09:12:44 -04:00
Dan Ribbens
3c9ee5d3b4 fix(db-*): migration batch not incrementing past 1 (#12215)
When `payload migrate` is run and a record with name "dev" is returned
having `batch: -1`, then the `batch` is not incrementing as expected as
it is stuck at 1. This change makes it so the batch is incremented from
the correct latest batch, ignoring the `name: "dev"` migration.
2025-05-05 09:11:10 -04:00
Germán Jabloñski
11018ebfe0 chore(live-preview-react): enable TypeScript strict (#12298) 2025-05-02 17:10:40 -03:00
Germán Jabloñski
b480f81387 chore(live-preview): enable TypeScript strict (live-preview-vue) (#12299) 2025-05-02 17:10:31 -03:00
Germán Jabloñski
d7d37447aa chore(storage-uploadthing): enable TypeScript strict (#12304) 2025-05-02 17:03:38 -03:00
Tobias Odendahl
ddf40d59ac fix(richtext-lexical): add missing line-breaks to plaintext conversion (#11951)
### What?
Adds line-breaks after headings, lists, list items, tables, table rows,
and table cells when converting lexical content to plaintext.

### Why?
Currently text from those nodes is concatenated without a separator.

### How?
Adds handling for these nodes to the plain text converter.
2025-05-02 15:24:24 -03:00
Tobias Odendahl
1ef1c5564d feat(ui): add option to open related documents in a new tab (#11939)
### What?
Selected documents in a relationship field can be opened in a new tab.

### Why?
Related documents can be edited using the edit icon which opens the
document in a drawer. Sometimes users would like to open the document in
a new tab instead to e.g. modify the related document at a later point
in time. This currently requires users to find the related document via
the list view and open it there. There is no easy way to find and open a
related document.

### How?
Adds custom handling to the relationship edit button to support opening
it in a new tab via middle-click, Ctrl+click, or right-click → 'Open in
new tab'.

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-05-02 13:03:51 -04:00
Bamsi
055a263af3 docs: fix typo in fields/relationship.mdx (#12306) 2025-05-02 16:39:45 +00:00
Philipp Schneider
a62cdc89d8 fix(ui): blockType ignored when merging server form state (#12207)
In this case, the `blockType` property is created on the server, but -
prior to this fix - was discarded on the client in
[`fieldReducer.ts`](https://github.com/payloadcms/payload/blob/main/packages/ui/src/forms/Form/fieldReducer.ts#L186-L198)
via
[`mergerServerFormState.ts`](b9832f40e4/packages/ui/src/forms/Form/mergeServerFormState.ts (L29-L31)),
because the field's path neither existed in the client's form state, nor
was it marked as `addedByServer`.

This caused later calls to POST requests to form state to send without
the `blockType` key for block rows, which in turn caused
`addFieldStatePromise.ts` to throw the following error:

```
Block with type "undefined" was found in block data, but no block with that type is defined in the config for field with schema path ${schemaPath}.
```

This prevented the client side form state update from completing, and if
the form state was saved, broke the document.

This is a follow-up to #12131, which treated the symptom, but not the
cause. The original issue seems to have been introduced in
https://github.com/payloadcms/payload/releases/tag/v3.34.0. It's unclear
to me whether this issue is connected to block E2E tests having been
disabled in the same release in
https://github.com/payloadcms/payload/pull/11988.

## How to reproduce

### Collection configuration

```ts
const RICH_TEXT_BLOCK_TYPE = 'richTextBlockType'

const RichTextBlock: Block = {
  slug: RICH_TEXT_BLOCK_TYPE,
  interfaceName: 'RichTextBlock',
  fields: [
    {
      name: 'richTextBlockField',
      label: 'Rich Text Field in Block Field',
      type: 'richText',
      editor: lexicalEditor({}),
      required: true,
    },
  ],
}

const MyCollection: CollectionConfig = {
  slug: 'my-collection-slug,
  fields: [
    {
      name: 'arrayField',
      label: 'Array Field',
      type: 'array',
      fields: [
        {
          name: 'blockField',
          type: 'blocks',
          blocks: [RichTextBlock],
          required: true,
        },
      ],
    },
  ]
}

export default MyCollection
```

### Steps

- Press "Add Array Field"
   -->  1st block with rich text is added
- Press "Add Array Field" a 2nd time

### Result
- 🛑 2nd block is indefinitely in loading state (side-note: the form UI
should preferably explicitly indicate the error).
- 🛑 If saving the document, it is corrupted and will only show a blank
page (also not indicating any error).

Client side:

<img width="1268" alt="Untitled"
src="https://github.com/user-attachments/assets/4b32fdeb-af76-41e2-9181-d2dbd686618a"
/>

API error:

<img width="1272" alt="image"
src="https://github.com/user-attachments/assets/35dc65f7-88ac-4397-b8d4-353bcf6a4bfd"
/>

Client side, when saving and re-opening document (API error of `GET
/admin/collections/${myCollection}/${documentId}` is the same (arguably
the HTTP response status code shouldn't be `200`)):

<img width="1281" alt="image"
src="https://github.com/user-attachments/assets/2e916eb5-6f10-4e82-9b84-1dc41db21d47"
/>

### Result after fix
- `blockType` is sent from the client to the server.
-  2nd block with rich text is added.
-  Document does not break when saving & re-opening.

<img width="1277" alt="Untitled"
src="https://github.com/user-attachments/assets/84d0c88b-64b2-48c4-864d-610d524ac8fc"
/>

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-05-02 10:18:11 -04:00
Tobias Odendahl
b6b02ac97c fix(ui): fix version list status for unpublished documents (#11983)
### What?
Fixes the label for documents which were the current published document
but got unpublished in the version view.

### Why?
If the most recent published document was unpublished, it remained
displayed as "Currently published version" in the version list.

### How?
Checks whether the document has a currently published version instead of
only looking at the latest published version when determining the label
in the versions view.

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

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-05-02 06:21:02 -07:00
qoheleth-tech
5365d4f1c2 docs: repair blank template markdown link in installation docs (#12297)
### What?
Fix link to "Blank Template" in installation.mdx so that it displays
correctly on the web.

### Why?
Text of broken md link looks bad.

### How?
Remove angle brackets.

### Fixes:
![2025-05-01 12 26 01 payloadcms com
aa355d5f4756](https://github.com/user-attachments/assets/6da465e9-49ba-4784-bdd9-37ead6ba374b)
2025-05-01 12:52:23 -07:00
Tobias Odendahl
e5683913b4 feat(ui): make select and relationship field placeholder configurable (#12253)
### What?
Allows to overwrite the default placeholder text of select and
relationship fields.

### Why?
The default placeholder text is generic. In some scenarios a custom
placeholder can guide the user better.

### How?
Adds a new property `admin.placeholder` to relationship and select field
which allows to define an alternative text or translation function for
the placeholder. The placeholder is used in the form fields as well as
in the filter options.

![Screenshot 2025-04-29 at 15 28
54](https://github.com/user-attachments/assets/d83d60c8-d4f6-41b7-951c-9f21c238afd8)
![Screenshot 2025-04-29 at 15 28
19](https://github.com/user-attachments/assets/d2263cf1-6042-4072-b5a9-e10af5f380bb)

---------

Co-authored-by: Dan Ribbens <DanRibbens@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-05-01 19:17:47 +00:00
Tobias Odendahl
78d3af7dc9 feat(ui): allow array fields to be filtered in list view (#11925)
### What?
Allows array fields to be filtered in the list view.

### Why?
Array fields were not filterable in the list view although all other
field types were filterable already.

### How?
Adds handling for array fields as filter option.


![image](https://github.com/user-attachments/assets/6df1a113-1d9f-4d50-92f7-d1fceed294d0)
2025-05-01 14:19:43 -04:00
Sasha
c08c7071ee fix(graphql): population of joins that target relationship fields that have relationTo as an array (#12289)
Fixes population of joins that target relationship fields that have
`relationTo` as an array, for example:
```ts
// Posts collection
{
  name: 'polymorphic',
  type: 'relationship',
  relationTo: ['categories', 'users'],
},
// Categories collection
{
  name: 'polymorphic',
  type: 'join',
  collection: 'posts',
  on: 'polymorphic',
}
```

Thanks @jaycetde for the integration test
https://github.com/payloadcms/payload/pull/12278!

---------

Co-authored-by: Jayce Pulsipher <jpulsipher@nav.com>
2025-05-01 14:04:42 -04:00
Samuel Gabriel
b9868c4a3b fix: allow custom admin user collection in query presets constraints (#12202)
Query preset "Specific User" constraints is currently fixed to `users`
collection. However, this will fail if one has a custom admin user collection.
2025-05-01 13:58:51 -04:00
Jessica Rynkar
e5b28c98dc fix(cpa): overwrites existing env variables (#10636)
### What?
Using `create-payload-app` to initialize Payload in an existing Next.js
app **that does not already have Payload installed** overwrites any
existing data in the `.env` and `.env.example` files.

The desired behavior is for Payload variables to get added with no
client data lost.

### How?
Updates `manageEnvFiles` to check for existing `.env / .env.example`
file and appends or creates as necessary.

Adds tests to
`packages/create-payload-app/src/lib/create-project.spec.ts`.

#### Fixes https://github.com/payloadcms/payload/issues/10355
2025-05-01 16:03:07 +00:00
Janus Reith
35c0404817 feat(live-preview): expose requestHandler to subscribe.ts (#10947)
### What?
As described in https://github.com/payloadcms/payload/discussions/10946,
allow passing a custom `collectionPopulationRequestHandler` function to
`subscribe`, which passes it along to `handleMessage` and `mergeData`

### Why?
`mergeData` already supports a custom function for this, that
functionality however isn't exposed.
My use case so far was passing along custom Authorization headers.


### How?
Move the functions type defined in `mergeData` to a dedicated
`CollectionPopulationRequestHandler` type, reuse it across `subscribe`,
`handleMessage` and `mergeData`.

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-04-30 15:08:53 -04:00
Elliot DeNolf
cfe8c97ab7 chore(release): v3.36.1 [skip ci] 2025-04-30 14:52:46 -04:00
Dan Ribbens
6133a1d183 perf: optimize file access promises (#12275)
Improves performance in local strategy uploads by reading the file and
metadata info synchronously. This change uses `promise.all` for three
separately awaited calls. This improves the perf by making all calls in
a non-blocking way.
2025-04-30 18:26:28 +00:00
Sasha
710fe0949b fix: duplicate with orderable (#12274)
Previously, duplication with orderable collections worked incorrectly,
for example

Document 1 is created - `_order: 'a5'`
Document 2 is duplicated from 1, - `_order: 'a5 - copy'` (result from
47a1eee765/packages/payload/src/fields/setDefaultBeforeDuplicate.ts (L6))

Now, the `_order` value is re-calculated properly.
2025-04-30 17:28:13 +00:00
Sasha
4a56597b92 fix(db-postgres): count crashes when query contains subqueries and doesn't return any rows (#12273)
Fixes https://github.com/payloadcms/payload/issues/12264

Uses safe object access in `countDistinct`, fallbacks to `0`
2025-04-30 16:53:36 +00:00
Sasha
27d644f2f9 perf(db-postgres): skip pagination overhead if limit: 0 is passed (#12261)
This improves performance when querying data in Postgers / SQLite with
`limit: 0`. Before, unless you additionally passed `pagination: false`
we executed additional count query to calculate the pagination. Now we
skip this as this is unnecessary since we can retrieve the count just
from `rows.length`.

This logic already existed in `db-mongodb` -
1b17df9e0b/packages/db-mongodb/src/find.ts (L114-L124)
2025-04-30 19:31:04 +03:00
Sasha
564fdb0e17 fix: virtual relationship fields with select (#12266)
Continuation of https://github.com/payloadcms/payload/pull/12265.

Currently, using `select` on new relationship virtual fields:
```
const doc = await payload.findByID({
  collection: 'virtual-relations',
  depth: 0,
  id,
  select: { postTitle: true },
})
```
doesn't work, because in order to calculate `post.title`, the `post`
field must be selected as well. This PR adds logic that sanitizes the
incoming `select` to include those relationships into `select` (that are
related to selected virtual fields)

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-04-30 12:27:04 -04:00
Dan Ribbens
47a1eee765 fix(plugin-import-export): csv export column order (#12258)
### What?
The order of fields, when specified for the create export function was
not used for constructing the data. Now the fields order will be used.

### Why?
This is important to building CSV data for consumption in other systems.

### How?
Adds logic to handle ordering the field values assigned to the export
data prior to building the CSV.
2025-04-29 15:28:16 -04:00
Mattias Grenhall
8fee0163b5 fix: update email regex to support special characters (#12181)
### What?
It's impossible to create a user with special characters in their email
in Payload CMS 3.35.0.

The issue is that currently the regex looks like this:

...payload/packages/payload/src/fields/validations.ts (line 202-203):
const emailRegex =
/^(?!.*\.\.)[\w.%+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i

This allows users that have the following characters in their email to
be created:
%, ., +, -

The regex needs to get updated to the following:

const emailRegex =
/^(?!.*\.\.)[\w!#$%&'*+/=?^{|}~.-]+@a-z0-9?(?:.a-z0-9?)*.[a-z]{2,}$/i`

This way all special characters `!#$%&'*+/=?^_{|}~.-`` are hereby OK to
have in the email.

I've added more test-cases to cover a couple of more scenarios in the
forked repo.


### Why?
The regex is missing some special characters that are allowed according
to standards.

### How?
* Go to the admin ui and try to create a user with any of the newly
added special characters meaning (!#$%&'*+/=?^_{|}~.-`)
* You should get a validation error. However with the addition of the
above code it should all check out.

Fixes #
https://github.com/payloadcms/payload/issues/12180

---------

Co-authored-by: Mattias Grenhall <mattias.grenhall@assaabloy.com>
2025-04-29 13:43:24 -04:00
Tobias Odendahl
1b17df9e0b fix(richtext-lexical): ensure state is up-to-date on inline-block restore (#12128)
### What?
Ensures that the initial state on inline blocks gets updated when an
inline block gets restored from lexical history.

### Why?
If an inline block got edited, removed, and restored (via lexical undo),
the state of the inline block was taken from an outdated initial state
and did not reflect the current form state, see screencast


https://github.com/user-attachments/assets/6f55ded3-57bc-4de0-8ac1-e49331674d5f

### How?
We now ensure that the initial state gets re-initialized after the
component got unmounted, resulting in the expected behavior:


https://github.com/user-attachments/assets/4e97eeb2-6dc4-49b1-91ca-35b59a93a348

---------

Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
2025-04-29 16:54:06 +00:00
Elliot DeNolf
3df1329e19 chore(release): v3.36.0 [skip ci] 2025-04-29 12:36:58 -04:00
Germán Jabloñski
5492542c1a fix(richtext-lexical): prevent extra paragraph when inserting blocks or uploadNodes. Add preemptive selection normalization (#12077)
Fixes #11628

PR #6389 caused bug #11628, which is a regression, as it had already
been fixed in #4441

It is likely that some things have changed because [Lexical had recently
made improvements](https://github.com/facebook/lexical/pull/7046) to
address selection normalization.

Although it wasn't necessary to resolve the issue, I added a
`NormalizeSelectionPlugin` to the editor, which makes selection handling
in the editor more robust.

I'm also adding a new collection to the Lexical test suite, intending it
to be used by default for most tests going forward. I've left an
explanatory comment on the dashboard.

___

Looking at #11628's video, it seems users also want to be able to
prevent the first paragraph from being empty. This makes sense to me, so
I think in another PR we could add a button at the top, just [like we
did at the bottom of the
editor](https://github.com/payloadcms/payload/pull/10530).
2025-04-29 15:57:46 +00:00
Tobias Odendahl
9948040ad2 perf(ui): only select necessary data for relationship options (#12251)
### What?
Improve performance of the relationship select options by reducing the
fetched documents to only necessary data.

### Why?
The relationship select only requires an ID and title. Fetching the
whole document instead leads to slow performance on collections with
large documents.

### How?
Add a select parameter to the query, the same way it is done in the
[WhereBuilder](https://github.com/payloadcms/payload/blob/main/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx#L105-L107)
already.
2025-04-29 11:50:00 -04:00
Jessica Rynkar
b7ae4ee60a docs: adds warning about handling different environments with migrations (#12249)
### What?
Migrating configs that include environment specific options can cause
issues and confusion for users.

### How?
Adds new section to `database/migrations` docs to highlight potential
issues with environment-specific settings when generating and running
migrations and includes some recommendations for addressing these
issues.

Closes #12241
2025-04-29 13:23:49 +01:00
Bjørn Nyborg
34ead72c85 fix(ui): copyToLocale should not pass any id's to avoid duplicates (#11887)
### What?
Using the `Copy To Locale` function causes validation errors on content
with `id` fields in postgres, since these should be unique.

```
key not found: error:valueMustBeUnique
key not found: error:followingFieldsInvalid
[13:11:29] ERROR: There was an error copying data from "en" to "de"
    err: {
      "type": "ValidationError",
      "message": "error:followingFieldsInvalid id",
      "stack":
          ValidationError: error:followingFieldsInvalid id
```

### Why?
In `packages/ui/src/utilities/copyDataFromLocale.ts` we are passing all
data from `fromLocaleData` including the `id` fields, which causes
duplicates on fields with unique id's like `Blocks` and `Arrays`.

### How?
To resolve this i implemented a function that recursively remove any
`id` field on the passed data.

### Fixes
- https://github.com/payloadcms/payload/issues/10684
- https://discord.com/channels/967097582721572934/1351497930984521800

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2025-04-29 08:23:40 +00:00
Dan Ribbens
caae5986f5 perf(plugin-search): reduce query depth in hooks (#12225)
Perf improvements and reliability of document reindexing and
synchronization of plugin-search functions.

## What

Reindex Handler (generateReindexHandler.ts):
- Replaced `Promise.all` with sequential `await` to prevent transaction
issues.
- Added `depth: 0` to payload.find for lighter queries.

Sync Operations (syncDocAsSearchIndex.ts):
- Standardized depth: 0 across create, delete, update, and find API
calls.
- Streamlined conditionals for create operations.

## Why
Improved performance with reduced query overhead.
Enhanced transaction safety by avoiding parallel database operations.
2025-04-28 22:32:26 -04:00
Dan Ribbens
2f21d46de6 perf(plugin-nested-docs): remove extra find call (#12224)
Reduce query by combining find and update into one local api call.
2025-04-28 22:25:53 -04:00
Dan Ribbens
6b83086c6c perf(graphql): skip count query for join field using simple pagination (#12223)
GraphQL requests with join fields result in a lot of extra count rows
queries that aren't necessary. This turns off pagination and uses
limit+1 and slice instead.
2025-04-28 22:25:14 -04:00
Sam Wheeler
5bd852c9b5 fix(ui): relationship using list drawer correctly updates when hasMany is true (#12176)
### What?

This fixes an issue raised by @maximseshuk in this PR #11553. Here is
the text of the original comment:

If the field has the property hasMany: true and you select one item, it
shows up in the select field, but any additional selected items won't be
visible in the select field, even though the data is actually there and
can be saved. After refreshing the page, they appear.

In addition I added a fix to an issue where the filterOptions weren't
being passed in to the useListDrawer hook properly in polymorphic
relationships

### How?

Instead of using the push method to update the value state, a new array
is created and directly set using useState. I think the issue was
because using push mutates the original array.
2025-04-28 16:38:50 -04:00
Adrian Maj
c85fb808b9 fix: user validation error inside the forgotPassword operation in the cases where user had localised fields (#12034)
### What?
So, while resetting the password using the Local API, I encountered a
validation error for localized fields. I jumped into the Payload
repository, and saw that `payload.update` is being used in the process,
with no locale specified/supported. This causes errors if the user has
localized fields, but specifying a locale for the password reset
operation would be silly, so I suggest turning this into a db operation,
just like the user fetching operation before.
### How?
I replaced this:
```TS
    user = await payload.update({
      id: user.id,
      collection: collectionConfig.slug,
      data: user,
      req,
    })
 ```
 With this:
 ```TS
     user = await payload.db.updateOne({
      id: user.id,
      collection: collectionConfig.slug,
      data: user,
      req,
    })
```
So the validation of other fields would be skipped in this operation. 
### Why?
This is the error I encountered while trying to reset password, it
blocks my project to go further :)
```bash
Error [ValidationError]: The following field is invalid: Data > Name
    at async sendOfferEmail (src/collections/Offers/components/SendEmailButton/index.tsx:18:20)
  16 |     try {
  17 |       const payload = await getPayload({ config });
> 18 |       const token = await payload.forgotPassword({
     |                    ^
  19 |         collection: "offers",
  20 |         data: {
{
  data: [Object],
  isOperational: true,
  isPublic: false,
  status: 400,
  [cause]: [Object]
}
cause:
{
  id: '67f4c1df8aa60189df9bdf5c',
  collection: 'offers',
  errors: [
    {
      label: 'Data > Name',
      message: 'This field is required.',
      path: 'name'
    }
  ],
  global: undefined
}
```

P.S The name field is totally fine, it is required and filled with
values in both locales I use, in admin panel I can edit and save
everything without any issues.


<!--

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-04-28 18:49:43 +00:00
Tylan Davis
ab03f4f305 fix(examples): incorrect documentation links for Live Preview example (#12233)
Fixes a couple links in the Live Preview example that were pointing to
`/docs/live-preview` instead of `/docs/live-preview/overview`.
2025-04-28 13:18:09 -04:00
Elliot DeNolf
2157450805 fix(next): pg-cloudflare build issue (#12242)
Fixes next build issue related to `cloudflare:sockets`.

Related Next.js discussion thread here:
https://github.com/vercel/next.js/discussions/50177

Commands to recreate issue locally

```sh
pnpm run script:pack --dest templates/with-postgres && \
pnpm run script:build-template-with-local-pkgs with-postgres postgresql://localhost:5432/payloadtests
```

**Build Error:**
```
Failed to compile.

cloudflare:sockets
Module build failed: UnhandledSchemeError: Reading from "cloudflare:sockets" is not handled by plugins (Unhandled scheme).
Webpack supports "data:" and "file:" URIs by default.
You may need an additional plugin to handle "cloudflare:" URIs.
    at /home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:408376
    at Hook.eval [as callAsync] (eval at create (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:14:9224), <anonymous>:6:1)
    at Hook.CALL_ASYNC_DELEGATE [as _callAsync] (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:14:6378)
    at Object.processResource (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:408301)
    at processResource (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/loader-runner/LoaderRunner.js:1:5308)
    at iteratePitchingLoaders (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/loader-runner/LoaderRunner.js:1:4667)
    at runLoaders (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/loader-runner/LoaderRunner.js:1:8590)
    at NormalModule._doBuild (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:408163)
    at NormalModule.build (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:410176)
    at /home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:82494
```
2025-04-28 13:04:33 -04:00
185 changed files with 3277 additions and 491 deletions

View File

@@ -298,3 +298,15 @@ Passing your migrations as shown above will tell Payload, in production only, to
may slow down serverless cold starts on platforms such as Vercel. Generally,
this option should only be used for long-running servers / containers.
</Banner>
## Environment-Specific Configurations and Migrations
Your configuration may include environment-specific settings (e.g., enabling a plugin only in production). If you generate migrations without considering the environment, it can lead to discrepancies and issues. When running migrations locally, Payload uses the development environment, which might miss production-specific configurations. Similarly, running migrations in production could miss development-specific entities.
This is an easy oversight, so be mindful of any environment-specific logic in your config when handling migrations.
**Ways to address this:**
- Manually update your migration file after it is generated to include any environment-specific configurations.
- Temporarily enable any required production environment variables in your local setup when generating the migration to capture the necessary updates.
- Use separate migration files for each environment to ensure the correct migration is executed in the corresponding environment.

View File

@@ -94,6 +94,7 @@ The Relationship Field inherits all of the default options from the base [Field
| **`allowCreate`** | Set to `false` if you'd like to disable the ability to create new documents from within the relationship field. |
| **`allowEdit`** | Set to `false` if you'd like to disable the ability to edit documents from within the relationship field. |
| **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More](#sort-options) |
| **`placeholder`** | Define a custom text or function to replace the generic default placeholder |
| **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. |
### Sort Options
@@ -149,7 +150,7 @@ The `filterOptions` property can either be a `Where` query, or a function return
| `id` | The `id` of the current document being edited. Will be `undefined` during the `create` operation or when called on a `Filter` component within the list view. |
| `relationTo` | The collection `slug` to filter against, limited to this field's `relationTo` property. |
| `req` | The Payload Request, which contains references to `payload`, `user`, `locale`, and more. |
| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. Will be an emprt object when called on a `Filter` component within the list view. |
| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. Will be an empty object when called on a `Filter` component within the list view. |
| `user` | An object containing the currently authenticated user. |
## Example

View File

@@ -89,6 +89,7 @@ The Select Field inherits all of the default options from the base [Field Admin
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **`isClearable`** | Set to `true` if you'd like this field to be clearable within the Admin UI. |
| **`isSortable`** | Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`) |
| **`placeholder`** | Define a custom text or function to replace the generic default placeholder |
## Example

View File

@@ -81,7 +81,7 @@ To install a Database Adapter, you can run **one** of the following commands:
#### 2. Copy Payload files into your Next.js app folder
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](<https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)>) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
```plaintext
app/

View File

@@ -55,10 +55,11 @@ All collection `find` queries are paginated automatically. Responses are returne
All Payload APIs support the pagination controls below. With them, you can create paginated lists of documents within your application:
| Control | Description |
| ------- | --------------------------------------- |
| `limit` | Limits the number of documents returned |
| `page` | Get a specific page number |
| Control | Default | Description |
| ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limit` | `10` | Limits the number of documents returned per page - set to `0` to show all documents, we automatically disabled pagination for you when `limit` is `0` for optimisation |
| `pagination` | `true` | Set to `false` to disable pagination and return all documents |
| `page` | `1` | Get a specific page number |
### Disabling pagination within Local API

View File

@@ -84,6 +84,7 @@ pnpm add @payloadcms/storage-s3
- 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.
- Configure `signedDownloads` (either globally of per-collection in `collections`) to use [presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) for files downloading. This can improve performance for large files (like videos) while still respecting your access control.
```ts
import { s3Storage } from '@payloadcms/storage-s3'

View File

@@ -58,7 +58,7 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
}
```
For more details on how to extend this functionality, see the [Live Preview](https://payloadcms.com/docs/live-preview) docs.
For more details on how to extend this functionality, see the [Live Preview](https://payloadcms.com/docs/live-preview/overview) docs.
## Front-end

View File

@@ -36,7 +36,7 @@ export const home: Partial<Page> = {
type: 'link',
children: [{ text: 'Live Preview' }],
newTab: true,
url: 'https://payloadcms.com/docs/live-preview',
url: 'https://payloadcms.com/docs/live-preview/overview',
},
{
text: ' you can edit this page in the admin panel and see the changes reflected here in real time.',

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.35.1",
"version": "3.37.0",
"private": true,
"type": "module",
"scripts": {

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import type { CliArgs, DbType, ProjectExample, ProjectTemplate } from '../types.
import { createProject } from './create-project.js'
import { dbReplacements } from './replacements.js'
import { getValidTemplates } from './templates.js'
import { manageEnvFiles } from './manage-env-files.js'
describe('createProject', () => {
let projectDir: string
@@ -154,5 +155,75 @@ describe('createProject', () => {
expect(content).toContain(dbReplacement.configReplacement().join('\n'))
})
})
describe('managing env files', () => {
it('updates .env files without overwriting existing data', async () => {
const envFilePath = path.join(projectDir, '.env')
const envExampleFilePath = path.join(projectDir, '.env.example')
fse.ensureDirSync(projectDir)
fse.ensureFileSync(envFilePath)
fse.ensureFileSync(envExampleFilePath)
const initialEnvContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\n`
const initialEnvExampleContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n`
fse.writeFileSync(envFilePath, initialEnvContent)
fse.writeFileSync(envExampleFilePath, initialEnvExampleContent)
await manageEnvFiles({
cliArgs: {
'--debug': true,
} as CliArgs,
databaseType: 'mongodb',
databaseUri: 'mongodb://localhost:27017/test',
payloadSecret: 'test-secret',
projectDir,
template: undefined,
})
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
expect(updatedEnvContent).toContain('CUSTOM_VAR=custom-value')
expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test')
expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret')
const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8')
expect(updatedEnvExampleContent).toContain('CUSTOM_VAR=custom-value')
expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test')
expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret')
})
it('creates .env and .env.example if they do not exist', async () => {
const envFilePath = path.join(projectDir, '.env')
const envExampleFilePath = path.join(projectDir, '.env.example')
fse.ensureDirSync(projectDir)
if (fse.existsSync(envFilePath)) fse.removeSync(envFilePath)
if (fse.existsSync(envExampleFilePath)) fse.removeSync(envExampleFilePath)
await manageEnvFiles({
cliArgs: {
'--debug': true,
} as CliArgs,
databaseUri: '',
payloadSecret: '',
projectDir,
template: undefined,
})
expect(fse.existsSync(envFilePath)).toBe(true)
expect(fse.existsSync(envExampleFilePath)).toBe(true)
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
expect(updatedEnvContent).toContain('DATABASE_URI=your-connection-string-here')
expect(updatedEnvContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE')
const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8')
expect(updatedEnvExampleContent).toContain('DATABASE_URI=your-connection-string-here')
expect(updatedEnvExampleContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE')
})
})
})
})

View File

@@ -6,66 +6,55 @@ import type { CliArgs, DbType, ProjectTemplate } from '../types.js'
import { debug, error } from '../utils/log.js'
import { dbChoiceRecord } from './select-db.js'
const updateEnvExampleVariables = (contents: string, databaseType: DbType | undefined): string => {
return contents
const updateEnvExampleVariables = (
contents: string,
databaseType: DbType | undefined,
payloadSecret?: string,
databaseUri?: string,
): string => {
const seenKeys = new Set<string>()
const updatedEnv = contents
.split('\n')
.map((line) => {
if (line.startsWith('#') || !line.includes('=')) {
return line // Preserve comments and unrelated lines
return line
}
const [key] = line.split('=')
if (!key) {return}
if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') {
const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null
if (dbChoice) {
const placeholderUri = `${dbChoice.dbConnectionPrefix}your-database-name${
dbChoice.dbConnectionSuffix || ''
}`
return databaseType === 'vercel-postgres'
? `POSTGRES_URL=${placeholderUri}`
: `DATABASE_URI=${placeholderUri}`
const placeholderUri = databaseUri
? databaseUri
: `${dbChoice.dbConnectionPrefix}your-database-name${dbChoice.dbConnectionSuffix || ''}`
line =
databaseType === 'vercel-postgres'
? `POSTGRES_URL=${placeholderUri}`
: `DATABASE_URI=${placeholderUri}`
}
return `DATABASE_URI=your-database-connection-here` // Fallback
}
if (key === 'PAYLOAD_SECRET' || key === 'PAYLOAD_SECRET_KEY') {
return `PAYLOAD_SECRET=YOUR_SECRET_HERE`
line = `PAYLOAD_SECRET=${payloadSecret || 'YOUR_SECRET_HERE'}`
}
// handles dupes
if (seenKeys.has(key)) {
return null
}
seenKeys.add(key)
return line
})
.filter(Boolean)
.reverse()
.join('\n')
}
const generateEnvContent = (
existingEnv: string,
databaseType: DbType | undefined,
databaseUri: string,
payloadSecret: string,
): string => {
const dbKey = databaseType === 'vercel-postgres' ? 'POSTGRES_URL' : 'DATABASE_URI'
const envVars: Record<string, string> = {}
existingEnv
.split('\n')
.filter((line) => line.includes('=') && !line.startsWith('#'))
.forEach((line) => {
const [key, value] = line.split('=')
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
envVars[key] = value
})
// Override specific keys
envVars[dbKey] = databaseUri
envVars['PAYLOAD_SECRET'] = payloadSecret
// Rebuild content
return Object.entries(envVars)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
return updatedEnv
}
/** Parse and swap .env.example values and write .env */
@@ -88,42 +77,71 @@ export async function manageEnvFiles(args: {
const envExamplePath = path.join(projectDir, '.env.example')
const envPath = path.join(projectDir, '.env')
const emptyEnvContent = `# Added by Payload\nDATABASE_URI=your-connection-string-here\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n`
try {
let updatedExampleContents: string
// Update .env.example
if (template?.type === 'starter') {
if (!fs.existsSync(envExamplePath)) {
error(`.env.example file not found at ${envExamplePath}`)
process.exit(1)
if (template?.type === 'plugin') {
if (debugFlag) {
debug(`plugin template detected - no .env added .env.example added`)
}
return
}
if (!fs.existsSync(envExamplePath)) {
updatedExampleContents = updateEnvExampleVariables(
emptyEnvContent,
databaseType,
payloadSecret,
databaseUri,
)
await fs.writeFile(envExamplePath, updatedExampleContents)
if (debugFlag) {
debug(`.env.example file successfully created`)
}
} else {
const envExampleContents = await fs.readFile(envExamplePath, 'utf8')
updatedExampleContents = updateEnvExampleVariables(envExampleContents, databaseType)
await fs.writeFile(envExamplePath, updatedExampleContents.trimEnd() + '\n')
const mergedEnvs = envExampleContents + '\n' + emptyEnvContent
updatedExampleContents = updateEnvExampleVariables(
mergedEnvs,
databaseType,
payloadSecret,
databaseUri,
)
await fs.writeFile(envExamplePath, updatedExampleContents)
if (debugFlag) {
debug(`.env.example file successfully updated`)
}
} else {
updatedExampleContents = `# Added by Payload\nDATABASE_URI=your-connection-string-here\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n`
await fs.writeFile(envExamplePath, updatedExampleContents.trimEnd() + '\n')
}
// Merge existing variables and create or update .env
const envExampleContents = await fs.readFile(envExamplePath, 'utf8')
const envContent = generateEnvContent(
envExampleContents,
databaseType,
databaseUri,
payloadSecret,
)
await fs.writeFile(envPath, `# Added by Payload\n${envContent.trimEnd()}\n`)
if (!fs.existsSync(envPath)) {
const envContent = updateEnvExampleVariables(
emptyEnvContent,
databaseType,
payloadSecret,
databaseUri,
)
await fs.writeFile(envPath, envContent)
if (debugFlag) {
debug(`.env file successfully created or updated`)
if (debugFlag) {
debug(`.env file successfully created`)
}
} else {
const envContents = await fs.readFile(envPath, 'utf8')
const mergedEnvs = envContents + '\n' + emptyEnvContent
const updatedEnvContents = updateEnvExampleVariables(
mergedEnvs,
databaseType,
payloadSecret,
databaseUri,
)
await fs.writeFile(envPath, updatedEnvContents)
if (debugFlag) {
debug(`.env file successfully updated`)
}
}
} catch (err: unknown) {
error('Unable to manage environment files')

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ export const countDistinct: CountDistinct = async function countDistinct(
})
.from(this.tables[tableName])
.where(where)
return Number(countResult[0]?.count)
return Number(countResult?.[0]?.count ?? 0)
}
let query: SQLiteSelect = db
@@ -39,5 +39,5 @@ export const countDistinct: CountDistinct = async function countDistinct(
// Instead, COUNT (GROUP BY id) can be used which is still slower than COUNT(*) but acceptable.
const countResult = await query
return Number(countResult[0]?.count)
return Number(countResult?.[0]?.count ?? 0)
}

View File

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

View File

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

View File

@@ -46,6 +46,7 @@ export const findMany = async function find({
const offset = skip || (page - 1) * limit
if (limit === 0) {
pagination = false
limit = undefined
}

View File

@@ -42,33 +42,36 @@ export const migrate: DrizzleAdapter['migrate'] = async function migrate(
limit: 0,
sort: '-name',
}))
if (migrationsInDB.find((m) => m.batch === -1)) {
const { confirm: runMigrations } = await prompts(
{
name: 'confirm',
type: 'confirm',
initial: false,
message:
"It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.\n\n" +
"If you'd like to run migrations, data loss will occur. Would you like to proceed?",
},
{
onCancel: () => {
process.exit(0)
},
},
)
if (!runMigrations) {
process.exit(0)
}
// ignore the dev migration so that the latest batch number increments correctly
migrationsInDB = migrationsInDB.filter((m) => m.batch !== -1)
}
if (Number(migrationsInDB?.[0]?.batch) > 0) {
latestBatch = Number(migrationsInDB[0]?.batch)
}
}
if (migrationsInDB.find((m) => m.batch === -1)) {
const { confirm: runMigrations } = await prompts(
{
name: 'confirm',
type: 'confirm',
initial: false,
message:
"It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.\n\n" +
"If you'd like to run migrations, data loss will occur. Would you like to proceed?",
},
{
onCancel: () => {
process.exit(0)
},
},
)
if (!runMigrations) {
process.exit(0)
}
}
const newBatch = latestBatch + 1
// Execute 'up' function for each migration sequentially

View File

@@ -16,7 +16,8 @@ export const countDistinct: CountDistinct = async function countDistinct(
})
.from(this.tables[tableName])
.where(where)
return Number(countResult[0].count)
return Number(countResult?.[0]?.count ?? 0)
}
let query = db
@@ -39,5 +40,5 @@ export const countDistinct: CountDistinct = async function countDistinct(
// Instead, COUNT (GROUP BY id) can be used which is still slower than COUNT(*) but acceptable.
const countResult = await query
return Number(countResult[0].count)
return Number(countResult?.[0]?.count ?? 0)
}

View File

@@ -36,7 +36,6 @@ type Args = {
*/
export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => {
const adapter = payload.db as unknown as BasePostgresAdapter
const db = await getTransaction(adapter, req)
const dir = payload.db.migrationDir
// get the drizzle migrateUpSQL from drizzle using the last schema
@@ -89,6 +88,8 @@ export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => {
payload.logger.info(addColumnsStatement)
}
const db = await getTransaction(adapter, req)
await db.execute(sql.raw(addColumnsStatement))
for (const collection of payload.config.collections) {

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ export type ObjectTypeConfig = {
type Args = {
baseFields?: ObjectTypeConfig
collectionSlug?: string
config: SanitizedConfig
fields: Field[]
forceNullable?: boolean
@@ -23,6 +24,7 @@ type Args = {
export function buildObjectType({
name,
baseFields = {},
collectionSlug,
config,
fields,
forceNullable,
@@ -43,6 +45,7 @@ export function buildObjectType({
return {
...objectTypeConfig,
...fieldSchema({
collectionSlug,
config,
field,
forceNullable,

View File

@@ -10,11 +10,11 @@ export const buildPaginatedListType = (name, docType) =>
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
hasPrevPage: { type: new GraphQLNonNull(GraphQLBoolean) },
limit: { type: new GraphQLNonNull(GraphQLInt) },
nextPage: { type: new GraphQLNonNull(GraphQLInt) },
nextPage: { type: GraphQLInt },
offset: { type: GraphQLInt },
page: { type: new GraphQLNonNull(GraphQLInt) },
pagingCounter: { type: new GraphQLNonNull(GraphQLInt) },
prevPage: { type: new GraphQLNonNull(GraphQLInt) },
prevPage: { type: GraphQLInt },
totalDocs: { type: new GraphQLNonNull(GraphQLInt) },
totalPages: { type: new GraphQLNonNull(GraphQLInt) },
},

View File

@@ -8,6 +8,7 @@ import type {
DateField,
EmailField,
Field,
FlattenedJoinField,
GraphQLInfo,
GroupField,
JoinField,
@@ -68,6 +69,7 @@ function formattedNameResolver({
}
type SharedArgs = {
collectionSlug?: string
config: SanitizedConfig
forceNullable?: boolean
graphqlResult: GraphQLInfo
@@ -340,7 +342,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
},
}
},
join: ({ field, graphqlResult, objectTypeConfig, parentName }) => {
join: ({ collectionSlug, field, graphqlResult, objectTypeConfig, parentName }) => {
const joinName = combineParentName(parentName, toWords(field.name, true))
const joinType = {
@@ -385,27 +387,54 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
const draft = Boolean(args.draft ?? context.req.query?.draft)
const fullWhere = combineQueries(where, {
[field.on]: { equals: parent._id ?? parent.id },
})
const targetField = (field as FlattenedJoinField).targetField
const fullWhere = combineQueries(
where,
Array.isArray(targetField.relationTo)
? {
[field.on]: {
equals: {
relationTo: collectionSlug,
value: parent._id ?? parent.id,
},
},
}
: {
[field.on]: { equals: parent._id ?? parent.id },
},
)
if (Array.isArray(collection)) {
throw new Error('GraphQL with array of join.field.collection is not implemented')
}
return await req.payload.find({
const { docs } = await req.payload.find({
collection,
depth: 0,
draft,
fallbackLocale: req.fallbackLocale,
limit,
// Fetch one extra document to determine if there are more documents beyond the requested limit (used for hasNextPage calculation).
limit: typeof limit === 'number' && limit > 0 ? limit + 1 : 0,
locale: req.locale,
overrideAccess: false,
page,
pagination: false,
req,
sort,
where: fullWhere,
})
let shouldSlice = false
if (typeof limit === 'number' && limit !== 0 && limit < docs.length) {
shouldSlice = true
}
return {
docs: shouldSlice ? docs.slice(0, -1) : docs,
hasNextPage: limit === 0 ? false : limit < docs.length,
}
},
}

View File

@@ -29,6 +29,7 @@ import { recursivelyBuildNestedPaths } from './recursivelyBuildNestedPaths.js'
import { withOperators } from './withOperators.js'
type Args = {
collectionSlug?: string
nestedFieldName?: string
parentName: string
}

View File

@@ -111,6 +111,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
collection.graphQL.type = buildObjectType({
name: singularName,
baseFields,
collectionSlug: collectionConfig.slug,
config,
fields,
forceNullable: forceNullableObjectType,
@@ -339,6 +340,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
collection.graphQL.versionType = buildObjectType({
name: `${singularName}Version`,
collectionSlug: collectionConfig.slug,
config,
fields: versionCollectionFields,
forceNullable: forceNullableObjectType,

View File

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

View File

@@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
// To prevent the flicker of stale data while the post message is being sent,
// you can conditionally render loading UI based on the `isLoading` state
export const useLivePreview = <T extends any>(props: {
export const useLivePreview = <T extends Record<string, unknown>>(props: {
apiRoute?: string
depth?: number
initialData: T
@@ -21,7 +21,7 @@ export const useLivePreview = <T extends any>(props: {
const [isLoading, setIsLoading] = useState<boolean>(true)
const hasSentReadyMessage = useRef<boolean>(false)
const onChange = useCallback((mergedData) => {
const onChange = useCallback((mergedData: T) => {
setData(mergedData)
setIsLoading(false)
}, [])

View File

@@ -1,9 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"strict": false,
"noUncheckedIndexedAccess": false,
},
"references": [{ "path": "../payload" }]
}

View File

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

View File

@@ -8,7 +8,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
*
* {@link https://payloadcms.com/docs/live-preview/frontend View the documentation}
*/
export const useLivePreview = <T>(props: {
export const useLivePreview = <T extends Record<string, unknown>>(props: {
apiRoute?: string
depth?: number
initialData: T
@@ -27,7 +27,7 @@ export const useLivePreview = <T>(props: {
isLoading.value = false
}
let subscription: (event: MessageEvent) => void
let subscription: (event: MessageEvent) => Promise<void> | void
onMounted(() => {
subscription = subscribe({

View File

@@ -1,9 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"strict": false,
"noUncheckedIndexedAccess": false,
},
"references": [{ "path": "../payload" }] // db-mongodb depends on payload
}

View File

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

View File

@@ -1,6 +1,6 @@
import type { FieldSchemaJSON } from 'payload'
import type { LivePreviewMessageEvent } from './types.js'
import type { CollectionPopulationRequestHandler, LivePreviewMessageEvent } from './types.js'
import { isLivePreviewEvent } from './isLivePreviewEvent.js'
import { mergeData } from './mergeData.js'
@@ -29,9 +29,10 @@ export const handleMessage = async <T extends Record<string, any>>(args: {
depth?: number
event: LivePreviewMessageEvent<T>
initialData: T
requestHandler?: CollectionPopulationRequestHandler
serverURL: string
}): Promise<T> => {
const { apiRoute, depth, event, initialData, serverURL } = args
const { apiRoute, depth, event, initialData, requestHandler, serverURL } = args
if (isLivePreviewEvent(event, serverURL)) {
const { data, externallyUpdatedRelationship, fieldSchemaJSON, locale } = event.data
@@ -57,6 +58,7 @@ export const handleMessage = async <T extends Record<string, any>>(args: {
incomingData: data,
initialData: _payloadLivePreview?.previousData || initialData,
locale,
requestHandler,
serverURL,
})

View File

@@ -1,6 +1,6 @@
import type { DocumentEvent, FieldSchemaJSON, PaginatedDocs } from 'payload'
import type { PopulationsByCollection } from './types.js'
import type { CollectionPopulationRequestHandler, PopulationsByCollection } from './types.js'
import { traverseFields } from './traverseFields.js'
@@ -29,21 +29,17 @@ let prevLocale: string | undefined
export const mergeData = async <T extends Record<string, any>>(args: {
apiRoute?: string
collectionPopulationRequestHandler?: ({
apiPath,
endpoint,
serverURL,
}: {
apiPath: string
endpoint: string
serverURL: string
}) => Promise<Response>
/**
* @deprecated Use `requestHandler` instead
*/
collectionPopulationRequestHandler?: CollectionPopulationRequestHandler
depth?: number
externallyUpdatedRelationship?: DocumentEvent
fieldSchema: FieldSchemaJSON
incomingData: Partial<T>
initialData: T
locale?: string
requestHandler?: CollectionPopulationRequestHandler
returnNumberOfRequests?: boolean
serverURL: string
}): Promise<
@@ -81,7 +77,8 @@ export const mergeData = async <T extends Record<string, any>>(args: {
let res: PaginatedDocs
const ids = new Set(populations.map(({ id }) => id))
const requestHandler = args.collectionPopulationRequestHandler || defaultRequestHandler
const requestHandler =
args.collectionPopulationRequestHandler || args.requestHandler || defaultRequestHandler
try {
res = await requestHandler({

View File

@@ -1,3 +1,5 @@
import type { CollectionPopulationRequestHandler } from './types.js'
import { handleMessage } from './handleMessage.js'
export const subscribe = <T extends Record<string, any>>(args: {
@@ -5,9 +7,10 @@ export const subscribe = <T extends Record<string, any>>(args: {
callback: (data: T) => void
depth?: number
initialData: T
requestHandler?: CollectionPopulationRequestHandler
serverURL: string
}): ((event: MessageEvent) => Promise<void> | void) => {
const { apiRoute, callback, depth, initialData, serverURL } = args
const { apiRoute, callback, depth, initialData, requestHandler, serverURL } = args
const onMessage = async (event: MessageEvent) => {
const mergedData = await handleMessage<T>({
@@ -15,6 +18,7 @@ export const subscribe = <T extends Record<string, any>>(args: {
depth,
event,
initialData,
requestHandler,
serverURL,
})

View File

@@ -1,5 +1,15 @@
import type { DocumentEvent, FieldSchemaJSON } from 'payload'
export type CollectionPopulationRequestHandler = ({
apiPath,
endpoint,
serverURL,
}: {
apiPath: string
endpoint: string
serverURL: string
}) => Promise<Response>
export type LivePreviewArgs = {}
export type LivePreview = void

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.35.1",
"version": "3.37.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -2,7 +2,14 @@
import type { PaginatedDocs, Where } from 'payload'
import { fieldBaseClass, Pill, ReactSelect, useConfig, useTranslation } from '@payloadcms/ui'
import {
fieldBaseClass,
Pill,
ReactSelect,
useConfig,
useDocumentInfo,
useTranslation,
} from '@payloadcms/ui'
import { formatDate } from '@payloadcms/ui/shared'
import { stringify } from 'qs-esm'
import React, { useCallback, useEffect, useState } from 'react'
@@ -37,6 +44,8 @@ export const SelectComparison: React.FC<Props> = (props) => {
},
} = useConfig()
const { hasPublishedDoc } = useDocumentInfo()
const [options, setOptions] = useState<
{
label: React.ReactNode | string
@@ -109,7 +118,10 @@ export const SelectComparison: React.FC<Props> = (props) => {
},
published: {
currentLabel: t('version:currentPublishedVersion'),
latestVersion: latestPublishedVersion,
// The latest published version does not necessarily equal the current published version,
// because the latest published version might have been unpublished in the meantime.
// Hence, we should only use the latest published version if there is a published document.
latestVersion: hasPublishedDoc ? latestPublishedVersion : undefined,
pillStyle: 'success',
previousLabel: t('version:previouslyPublished'),
},

View File

@@ -85,13 +85,34 @@ export async function VersionsView(props: DocumentViewServerProps) {
payload,
status: 'draft',
})
latestPublishedVersion = await getLatestVersion({
slug: collectionSlug,
type: 'collection',
parentID: id,
payload,
status: 'published',
const publishedDoc = await payload.count({
collection: collectionSlug,
depth: 0,
overrideAccess: true,
req,
where: {
id: {
equals: id,
},
_status: {
equals: 'published',
},
},
})
// If we pass a latestPublishedVersion to buildVersionColumns,
// this will be used to display it as the "current published version".
// However, the latest published version might have been unpublished in the meantime.
// Hence, we should only pass the latest published version if there is a published document.
latestPublishedVersion =
publishedDoc.totalDocs > 0 &&
(await getLatestVersion({
slug: collectionSlug,
type: 'collection',
parentID: id,
payload,
status: 'published',
}))
}
} catch (err) {
logError({ err, payload })

View File

@@ -140,6 +140,13 @@ export const withPayload = (nextConfig = {}, options = {}) => {
{ module: /node_modules\/mongodb\/lib\/bson\.js/ },
{ file: /node_modules\/mongodb\/lib\/bson\.js/ },
],
plugins: [
...(incomingWebpackConfig?.plugins || []),
// Fix cloudflare:sockets error: https://github.com/vercel/next.js/discussions/50177
new webpackOptions.webpack.IgnorePlugin({
resourceRegExp: /^pg-native$|^cloudflare:sockets$/,
}),
],
resolve: {
...(incomingWebpackConfig?.resolve || {}),
alias: {

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
const isLocked = (date: number): boolean => {
if (!date) {
return false
}
return date > Date.now()
}
export default isLocked

View File

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

View File

@@ -138,15 +138,17 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(
return null
}
user.resetPasswordToken = token
user.resetPasswordExpiration = new Date(
const resetPasswordExpiration = new Date(
Date.now() + (collectionConfig.auth?.forgotPassword?.expiration ?? expiration ?? 3600000),
).toISOString()
user = await payload.update({
id: user.id,
collection: collectionConfig.slug,
data: user,
data: {
resetPasswordExpiration,
resetPasswordToken: token,
},
req,
})

View File

@@ -3,6 +3,7 @@ import type {
AuthOperationsFromCollectionSlug,
Collection,
DataFromCollectionSlug,
SanitizedCollectionConfig,
} from '../../collections/config/types.js'
import type { CollectionSlug } from '../../index.js'
import type { PayloadRequest, Where } from '../../types/index.js'
@@ -21,7 +22,7 @@ import { killTransaction } from '../../utilities/killTransaction.js'
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'
import { getFieldsToSign } from '../getFieldsToSign.js'
import { getLoginOptions } from '../getLoginOptions.js'
import isLocked from '../isLocked.js'
import { isUserLocked } from '../isUserLocked.js'
import { jwtSign } from '../jwt.js'
import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js'
@@ -42,6 +43,32 @@ export type Arguments<TSlug extends CollectionSlug> = {
showHiddenFields?: boolean
}
type CheckLoginPermissionArgs = {
collection: SanitizedCollectionConfig
loggingInWithUsername?: boolean
req: PayloadRequest
user: any
}
export const checkLoginPermission = ({
collection,
loggingInWithUsername,
req,
user,
}: CheckLoginPermissionArgs) => {
if (!user) {
throw new AuthenticationError(req.t, Boolean(loggingInWithUsername))
}
if (collection.auth.verify && user._verified === false) {
throw new UnverifiedEmail({ t: req.t })
}
if (isUserLocked(new Date(user.lockUntil).getTime())) {
throw new LockedAuth(req.t)
}
}
export const loginOperation = async <TSlug extends CollectionSlug>(
incomingArgs: Arguments<TSlug>,
): Promise<{ user: DataFromCollectionSlug<TSlug> } & Result> => {
@@ -184,21 +211,16 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
where: whereConstraint,
})
if (!user) {
throw new AuthenticationError(req.t, Boolean(canLoginWithUsername && sanitizedUsername))
}
if (args.collection.config.auth.verify && user._verified === false) {
throw new UnverifiedEmail({ t: req.t })
}
checkLoginPermission({
collection: collectionConfig,
loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername),
req,
user,
})
user.collection = collectionConfig.slug
user._strategy = 'local-jwt'
if (isLocked(new Date(user.lockUntil).getTime())) {
throw new LockedAuth(req.t)
}
const authResult = await authenticateLocalStrategy({ doc: user, password })
user = sanitizeInternalFields(user)

View File

@@ -247,6 +247,7 @@ export const createOperation = async <
let doc
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -110,6 +110,7 @@ export const deleteOperation = async <
const fullWhere = combineQueries(where, accessResult)
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -168,6 +168,7 @@ export const deleteByIDOperation = async <TSlug extends CollectionSlug, TSelect
}
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -102,6 +102,7 @@ export const findOperation = async <
} = args
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -87,6 +87,7 @@ export const findByIDOperation = async <
} = args
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -11,6 +11,7 @@ import { APIError, Forbidden, NotFound } from '../../errors/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { sanitizeSelect } from '../../utilities/sanitizeSelect.js'
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js'
export type Arguments = {
@@ -70,8 +71,10 @@ export const findVersionByIDOperation = async <TData extends TypeWithID = any>(
// /////////////////////////////////////
const select = sanitizeSelect({
fields: buildVersionCollectionFields(payload.config, collectionConfig, true),
forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }),
select: incomingSelect,
versions: true,
})
const versionsQuery = await payload.db.findVersions<TData>({

View File

@@ -72,8 +72,10 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
const fullWhere = combineQueries(where, accessResults)
const select = sanitizeSelect({
fields: buildVersionCollectionFields(payload.config, collectionConfig, true),
forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }),
select: incomingSelect,
versions: true,
})
// /////////////////////////////////////

View File

@@ -117,6 +117,7 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
// /////////////////////////////////////
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -201,6 +201,7 @@ export const updateOperation = async <
try {
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -161,6 +161,7 @@ export const updateByIDOperation = async <
})
const select = sanitizeSelect({
fields: collectionConfig.flattenedFields,
forceSelect: collectionConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -83,6 +83,13 @@ export const addOrderableFieldsAndHook = (
hidden: true,
readOnly: true,
},
hooks: {
beforeDuplicate: [
({ siblingData }) => {
delete siblingData[orderableFieldName]
},
],
},
index: true,
required: true,
// override the schema to make order fields optional for payload.create()
@@ -275,5 +282,6 @@ export const addOrderableEndpoint = (config: SanitizedConfig) => {
if (!config.endpoints) {
config.endpoints = []
}
config.endpoints.push(reorderEndpoint)
}

View File

@@ -1061,6 +1061,7 @@ export type SelectField = {
} & Admin['components']
isClearable?: boolean
isSortable?: boolean
placeholder?: LabelFunction | string
} & Admin
/**
* Customize the SQL table name
@@ -1093,7 +1094,7 @@ export type SelectField = {
Omit<FieldBase, 'validate'>
export type SelectFieldClient = {
admin?: AdminClient & Pick<SelectField['admin'], 'isClearable' | 'isSortable'>
admin?: AdminClient & Pick<SelectField['admin'], 'isClearable' | 'isSortable' | 'placeholder'>
} & FieldBaseClient &
Pick<SelectField, 'hasMany' | 'interfaceName' | 'options' | 'type'>
@@ -1160,10 +1161,11 @@ type RelationshipAdmin = {
>
} & Admin['components']
isSortable?: boolean
placeholder?: LabelFunction | string
} & Admin
type RelationshipAdminClient = AdminClient &
Pick<RelationshipAdmin, 'allowCreate' | 'allowEdit' | 'appearance' | 'isSortable'>
Pick<RelationshipAdmin, 'allowCreate' | 'allowEdit' | 'appearance' | 'isSortable' | 'placeholder'>
export type PolymorphicRelationshipField = {
admin?: {

View File

@@ -200,7 +200,7 @@ export const email: EmailFieldValidation = (
* Supports multiple subdomains (e.g., user@sub.domain.example.com)
*/
const emailRegex =
/^(?!.*\.\.)[\w.%+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i
/^(?!.*\.\.)[\w!#$%&'*+/=?^`{|}~-](?:[\w!#$%&'*+/=?^`{|}~.-]*[\w!#$%&'*+/=?^`{|}~-])?@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i
if ((value && !emailRegex.test(value)) || (!value && required)) {
return t('validation:emailAddress')

View File

@@ -53,6 +53,7 @@ export const findOneOperation = async <T extends Record<string, unknown>>(
}
const select = sanitizeSelect({
fields: globalConfig.flattenedFields,
forceSelect: globalConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -11,6 +11,8 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { sanitizeSelect } from '../../utilities/sanitizeSelect.js'
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
import { buildVersionGlobalFields } from '../../versions/buildGlobalFields.js'
import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js'
export type Arguments = {
@@ -60,8 +62,10 @@ export const findVersionByIDOperation = async <T extends TypeWithVersion<T> = an
const hasWhereAccess = typeof accessResults === 'object'
const select = sanitizeSelect({
fields: buildVersionGlobalFields(payload.config, globalConfig, true),
forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }),
select: incomingSelect,
versions: true,
})
const findGlobalVersionsArgs: FindGlobalVersionsArgs = {

View File

@@ -70,8 +70,10 @@ export const findVersionsOperation = async <T extends TypeWithVersion<T>>(
const fullWhere = combineQueries(where, accessResults)
const select = sanitizeSelect({
fields: buildVersionGlobalFields(payload.config, globalConfig, true),
forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }),
select: incomingSelect,
versions: true,
})
// /////////////////////////////////////

View File

@@ -246,6 +246,7 @@ export const updateOperation = async <
// /////////////////////////////////////
const select = sanitizeSelect({
fields: globalConfig.flattenedFields,
forceSelect: globalConfig.forceSelect,
select: incomingSelect,
})

View File

@@ -89,6 +89,10 @@ import { traverseFields } from './utilities/traverseFields.js'
export { default as executeAccess } from './auth/executeAccess.js'
export { executeAuthStrategies } from './auth/executeAuthStrategies.js'
export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js'
export { getAccessResults } from './auth/getAccessResults.js'
export { getFieldsToSign } from './auth/getFieldsToSign.js'
export { getLoginOptions } from './auth/getLoginOptions.js'
export interface GeneratedTypes {
authUntyped: {
@@ -977,13 +981,12 @@ interface RequestContext {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DatabaseAdapter extends BaseDatabaseAdapter {}
export type { Payload, RequestContext }
export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js'
export { getAccessResults } from './auth/getAccessResults.js'
export { getFieldsToSign } from './auth/getFieldsToSign.js'
export * from './auth/index.js'
export { jwtSign } from './auth/jwt.js'
export { accessOperation } from './auth/operations/access.js'
export { forgotPasswordOperation } from './auth/operations/forgotPassword.js'
export { initOperation } from './auth/operations/init.js'
export { checkLoginPermission } from './auth/operations/login.js'
export { loginOperation } from './auth/operations/login.js'
export { logoutOperation } from './auth/operations/logout.js'
export type { MeOperationResult } from './auth/operations/me.js'
@@ -994,6 +997,8 @@ export { resetPasswordOperation } from './auth/operations/resetPassword.js'
export { unlockOperation } from './auth/operations/unlock.js'
export { verifyEmailOperation } from './auth/operations/verifyEmail.js'
export { JWTAuthentication } from './auth/strategies/jwt.js'
export { incrementLoginAttempts } from './auth/strategies/local/incrementLoginAttempts.js'
export { resetLoginAttempts } from './auth/strategies/local/resetLoginAttempts.js'
export type {
AuthStrategyFunction,
AuthStrategyFunctionArgs,
@@ -1201,6 +1206,7 @@ export {
MissingFile,
NotFound,
QueryError,
UnverifiedEmail,
ValidationError,
ValidationErrorName,
} from './errors/index.js'

View File

@@ -74,7 +74,7 @@ export const getConstraints = (config: Config): Field => ({
},
],
},
relationTo: 'users',
relationTo: config.admin?.user ?? 'users', // TODO: remove this fallback when the args are properly typed as `SanitizedConfig`
},
...(config?.queryPresets?.constraints?.[operation]?.reduce(
(acc: Field[], option: QueryPresetConstraint) => {

View File

@@ -10,7 +10,7 @@ import type { SanitizedConfig } from '../config/types.js'
import type { PayloadRequest } from '../types/index.js'
import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types.js'
import { FileRetrievalError, FileUploadError, MissingFile } from '../errors/index.js'
import { FileRetrievalError, FileUploadError, Forbidden, MissingFile } from '../errors/index.js'
import { canResizeImage } from './canResizeImage.js'
import { cropImage } from './cropImage.js'
import { getExternalFile } from './getExternalFile.js'
@@ -85,6 +85,10 @@ export const generateFileData = async <T>({
if (!file && uploadEdits && incomingFileData) {
const { filename, url } = incomingFileData as FileData
if (filename && (filename.includes('../') || filename.includes('..\\'))) {
throw new Forbidden(req.t)
}
try {
if (url && url.startsWith('/') && !disableLocalStorage) {
const filePath = `${staticPath}/${filename}`

View File

@@ -5,28 +5,28 @@ import path from 'path'
import type { PayloadRequest } from '../types/index.js'
const mimeTypeEstimate = {
const mimeTypeEstimate: Record<string, string> = {
svg: 'image/svg+xml',
}
export const getFileByPath = async (filePath: string): Promise<PayloadRequest['file']> => {
if (typeof filePath === 'string') {
const data = await fs.readFile(filePath)
const mimetype = fileTypeFromFile(filePath)
const { size } = await fs.stat(filePath)
const name = path.basename(filePath)
const ext = path.extname(filePath).slice(1)
const mime = (await mimetype)?.mime || mimeTypeEstimate[ext]
return {
name,
data,
mimetype: mime,
size,
}
if (typeof filePath !== 'string') {
return undefined
}
return undefined
const name = path.basename(filePath)
const ext = path.extname(filePath).slice(1)
const [data, stat, type] = await Promise.all([
fs.readFile(filePath),
fs.stat(filePath),
fileTypeFromFile(filePath),
])
return {
name,
data,
mimetype: type?.mime || mimeTypeEstimate[ext],
size: stat.size,
}
}

View File

@@ -1,17 +1,129 @@
import { deepMergeSimple } from '@payloadcms/translations/utilities'
import type { SelectType } from '../types/index.js'
import type { FlattenedField } from '../fields/config/types.js'
import type { SelectIncludeType, SelectType } from '../types/index.js'
import { getSelectMode } from './getSelectMode.js'
// Transform post.title -> post, post.category.title -> post
const stripVirtualPathToCurrentCollection = ({
fields,
path,
versions,
}: {
fields: FlattenedField[]
path: string
versions: boolean
}) => {
const resultSegments: string[] = []
if (versions) {
resultSegments.push('version')
const versionField = fields.find((each) => each.name === 'version')
if (versionField && versionField.type === 'group') {
fields = versionField.flattenedFields
}
}
for (const segment of path.split('.')) {
const field = fields.find((each) => each.name === segment)
if (!field) {
continue
}
resultSegments.push(segment)
if (field.type === 'relationship' || field.type === 'upload') {
return resultSegments.join('.')
}
}
return resultSegments.join('.')
}
const getAllVirtualRelations = ({ fields }: { fields: FlattenedField[] }) => {
const result: string[] = []
for (const field of fields) {
if ('virtual' in field && typeof field.virtual === 'string') {
result.push(field.virtual)
} else if (field.type === 'group' || field.type === 'tab') {
const nestedResult = getAllVirtualRelations({ fields: field.flattenedFields })
for (const nestedItem of nestedResult) {
result.push(nestedItem)
}
}
}
return result
}
const resolveVirtualRelationsToSelect = ({
fields,
selectValue,
topLevelFields,
versions,
}: {
fields: FlattenedField[]
selectValue: SelectIncludeType | true
topLevelFields: FlattenedField[]
versions: boolean
}) => {
const result: string[] = []
if (selectValue === true) {
for (const item of getAllVirtualRelations({ fields })) {
result.push(
stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }),
)
}
} else {
for (const fieldName in selectValue) {
const field = fields.find((each) => each.name === fieldName)
if (!field) {
continue
}
if ('virtual' in field && typeof field.virtual === 'string') {
result.push(
stripVirtualPathToCurrentCollection({
fields: topLevelFields,
path: field.virtual,
versions,
}),
)
} else if (field.type === 'group' || field.type === 'tab') {
for (const item of resolveVirtualRelationsToSelect({
fields: field.flattenedFields,
selectValue: selectValue[fieldName],
topLevelFields,
versions,
})) {
result.push(
stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }),
)
}
}
}
}
return result
}
export const sanitizeSelect = ({
fields,
forceSelect,
select,
versions,
}: {
fields: FlattenedField[]
forceSelect?: SelectType
select?: SelectType
versions?: boolean
}): SelectType | undefined => {
if (!forceSelect || !select) {
if (!select) {
return select
}
@@ -21,5 +133,36 @@ export const sanitizeSelect = ({
return select
}
return deepMergeSimple(select, forceSelect)
if (forceSelect) {
select = deepMergeSimple(select, forceSelect)
}
if (select) {
const virtualRelations = resolveVirtualRelationsToSelect({
fields,
selectValue: select as SelectIncludeType,
topLevelFields: fields,
versions: versions ?? false,
})
for (const path of virtualRelations) {
let currentRef = select
const segments = path.split('.')
for (let i = 0; i < segments.length; i++) {
const isLast = segments.length - 1 === i
const segment = segments[i]
if (isLast) {
currentRef[segment] = true
} else {
if (!(segment in currentRef)) {
currentRef[segment] = {}
currentRef = currentRef[segment]
}
}
}
}
}
return select
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.35.1",
"version": "3.37.0",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -26,6 +26,7 @@ export async function getFilePrefix({
const files = await req.payload.find({
collection: collection.slug,
depth: 0,
draft: true,
limit: 1,
pagination: false,
where: {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.35.1",
"version": "3.37.0",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-import-export",
"version": "3.35.1",
"version": "3.37.0",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",

View File

@@ -87,7 +87,7 @@ export const createExport = async (args: CreateExportArgs) => {
let isFirstBatch = true
while (result.docs.length > 0) {
const csvInput = result.docs.map((doc) => flattenObject(doc))
const csvInput = result.docs.map((doc) => flattenObject({ doc, fields }))
const csvString = stringify(csvInput, { header: isFirstBatch })
this.push(encoder.encode(csvString))
isFirstBatch = false
@@ -119,7 +119,7 @@ export const createExport = async (args: CreateExportArgs) => {
result = await payload.find(findArgs)
if (isCSV) {
const csvInput = result.docs.map((doc) => flattenObject(doc))
const csvInput = result.docs.map((doc) => flattenObject({ doc, fields }))
outputData.push(stringify(csvInput, { header: isFirstBatch }))
isFirstBatch = false
} else {

View File

@@ -1,23 +1,61 @@
export const flattenObject = (obj: any, prefix: string = ''): Record<string, unknown> => {
import type { Document } from 'payload'
type Args = {
doc: Document
fields?: string[]
prefix?: string
}
export const flattenObject = ({ doc, fields, prefix }: Args): Record<string, unknown> => {
const result: Record<string, unknown> = {}
Object.entries(obj).forEach(([key, value]) => {
const newKey = prefix ? `${prefix}_${key}` : key
const flatten = (doc: Document, prefix?: string) => {
Object.entries(doc).forEach(([key, value]) => {
const newKey = prefix ? `${prefix}_${key}` : key
if (Array.isArray(value)) {
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
Object.assign(result, flattenObject(item, `${newKey}_${index}`))
} else {
result[`${newKey}_${index}`] = item
}
})
} else if (typeof value === 'object' && value !== null) {
Object.assign(result, flattenObject(value, newKey))
} else {
result[newKey] = value
if (Array.isArray(value)) {
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
flatten(item, `${newKey}_${index}`)
} else {
result[`${newKey}_${index}`] = item
}
})
} else if (typeof value === 'object' && value !== null) {
flatten(value, newKey)
} else {
result[newKey] = value
}
})
}
flatten(doc, prefix)
if (fields) {
const orderedResult: Record<string, unknown> = {}
const fieldToRegex = (field: string): RegExp => {
const parts = field.split('.').map((part) => `${part}(?:_\\d+)?`)
const pattern = `^${parts.join('_')}`
return new RegExp(pattern)
}
})
fields.forEach((field) => {
if (result[field.replace(/\./g, '_')]) {
const sanitizedField = field.replace(/\./g, '_')
orderedResult[sanitizedField] = result[sanitizedField]
} else {
const regex = fieldToRegex(field)
Object.keys(result).forEach((key) => {
if (regex.test(key)) {
orderedResult[key] = result[key]
}
})
}
})
return orderedResult
}
return result
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-multi-tenant",
"version": "3.35.1",
"version": "3.37.0",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",

View File

@@ -14,6 +14,7 @@ export const findTenantOptions = async ({
useAsTitle,
user,
}: Args): Promise<PaginatedDocs> => {
const isOrderable = payload.collections[tenantsCollectionSlug]?.config?.orderable || false
return payload.find({
collection: tenantsCollectionSlug,
depth: 0,
@@ -21,8 +22,9 @@ export const findTenantOptions = async ({
overrideAccess: false,
select: {
[useAsTitle]: true,
...(isOrderable ? { _order: true } : {}),
},
sort: useAsTitle,
sort: isOrderable ? '_order' : useAsTitle,
user,
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.35.1",
"version": "3.37.0",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -22,7 +22,6 @@ type ResaveArgs = {
const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs) => {
const parentSlug = pluginConfig?.parentFieldSlug || 'parent'
const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs'
if (draft) {
// If the parent is a draft, don't resave children

View File

@@ -8,47 +8,39 @@ import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js'
export const resaveSelfAfterCreate =
(pluginConfig: NestedDocsPluginConfig, collection: CollectionConfig): CollectionAfterChangeHook =>
async ({ doc, operation, req }) => {
if (operation !== 'create') {
return undefined
}
const { locale, payload } = req
const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs'
const breadcrumbs = doc[breadcrumbSlug] as unknown as Breadcrumb[]
if (operation === 'create') {
const originalDocWithDepth0 = await payload.findByID({
const updateAsDraft =
typeof collection.versions === 'object' &&
collection.versions.drafts &&
doc._status !== 'published'
try {
await payload.update({
id: doc.id,
collection: collection.slug,
data: {
[breadcrumbSlug]:
breadcrumbs?.map((crumb, i) => ({
...crumb,
doc: breadcrumbs.length === i + 1 ? doc.id : crumb.doc,
})) || [],
},
depth: 0,
draft: updateAsDraft,
locale,
req,
})
const updateAsDraft =
typeof collection.versions === 'object' &&
collection.versions.drafts &&
doc._status !== 'published'
try {
await payload.update({
id: doc.id,
collection: collection.slug,
data: {
...originalDocWithDepth0,
[breadcrumbSlug]:
breadcrumbs?.map((crumb, i) => ({
...crumb,
doc: breadcrumbs.length === i + 1 ? doc.id : crumb.doc,
})) || [],
},
depth: 0,
draft: updateAsDraft,
locale,
req,
})
} catch (err: unknown) {
payload.logger.error(
`Nested Docs plugin has had an error while adding breadcrumbs during document creation.`,
)
payload.logger.error(err)
}
} catch (err: unknown) {
payload.logger.error(
`Nested Docs plugin has had an error while adding breadcrumbs during document creation.`,
)
payload.logger.error(err)
}
return undefined
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.35.1",
"version": "3.37.0",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.35.1",
"version": "3.37.0",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -124,14 +124,15 @@ export const generateReindexHandler =
for (let i = 0; i < totalBatches; i++) {
const { docs } = await payload.find({
collection,
depth: 0,
limit: batchSize,
locale: localeToSync,
page: i + 1,
...defaultLocalApiProps,
})
const promises = docs.map((doc) =>
syncDocAsSearchIndex({
for (const doc of docs) {
await syncDocAsSearchIndex({
collection,
doc,
locale: localeToSync,
@@ -139,12 +140,7 @@ export const generateReindexHandler =
operation,
pluginConfig,
req,
}),
)
// Sequentially await promises to avoid transaction issues
for (const promise of promises) {
await promise
})
}
}
}

View File

@@ -64,18 +64,17 @@ export const syncDocAsSearchIndex = async ({
const doSync = syncDrafts || (!syncDrafts && status !== 'draft')
try {
if (operation === 'create') {
if (doSync) {
await payload.create({
collection: searchSlug,
data: {
...dataToSave,
priority: defaultPriority,
},
locale: syncLocale,
req,
})
}
if (operation === 'create' && doSync) {
await payload.create({
collection: searchSlug,
data: {
...dataToSave,
priority: defaultPriority,
},
depth: 0,
locale: syncLocale,
req,
})
}
if (operation === 'update') {
@@ -110,6 +109,7 @@ export const syncDocAsSearchIndex = async ({
const duplicativeDocIDs = duplicativeDocs.map(({ id }) => id)
await payload.delete({
collection: searchSlug,
depth: 0,
req,
where: { id: { in: duplicativeDocIDs } },
})
@@ -134,6 +134,7 @@ export const syncDocAsSearchIndex = async ({
...dataToSave,
priority: foundDoc.priority || defaultPriority,
},
depth: 0,
locale: syncLocale,
req,
})
@@ -148,6 +149,7 @@ export const syncDocAsSearchIndex = async ({
docs: [docWithPublish],
} = await payload.find({
collection,
depth: 0,
draft: false,
limit: 1,
locale: syncLocale,
@@ -175,6 +177,7 @@ export const syncDocAsSearchIndex = async ({
await payload.delete({
id: searchDocID,
collection: searchSlug,
depth: 0,
req,
})
} catch (err: unknown) {
@@ -190,6 +193,7 @@ export const syncDocAsSearchIndex = async ({
...dataToSave,
priority: defaultPriority,
},
depth: 0,
locale: syncLocale,
req,
})

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-sentry",
"version": "3.35.1",
"version": "3.37.0",
"description": "Sentry plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.35.1",
"version": "3.37.0",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.35.1",
"version": "3.37.0",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.35.1",
"version": "3.37.0",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -284,10 +284,22 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
)
// cleanup effect
useEffect(() => {
const isStateOutOfSync = (formData: InlineBlockFields, initialState: FormState) => {
return Object.keys(initialState).some(
(key) => initialState[key] && formData[key] !== initialState[key].value,
)
}
return () => {
// If the component is unmounted (either via removeInlineBlock or via lexical itself) and the form state got changed before,
// we need to reset the initial state to force a re-fetch of the initial state when it gets mounted again (e.g. via lexical history undo).
// Otherwise it would use an outdated initial state.
if (initialState && isStateOutOfSync(formData, initialState)) {
setInitialState(false)
}
abortAndIgnore(onChangeAbortControllerRef.current)
}
}, [])
}, [formData, initialState])
/**
* HANDLE FORM SUBMIT

View File

@@ -56,22 +56,15 @@ export const BlocksPlugin: PluginComponent = () => {
if ($isRangeSelection(selection)) {
const blockNode = $createBlockNode(payload)
// we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert blocks node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(blockNode)
const { focus } = selection
const focusNode = focus.getNode()
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
focusNode.getTextContentSize() === 0 &&
focusNode
.getParentOrThrow()
.getChildren()
.filter((node) => $isParagraphNode(node)).length > 1
) {
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
}
}

View File

@@ -5,6 +5,12 @@ import type {
SerializedParagraphNode,
SerializedTextNode,
SerializedLineBreakNode,
SerializedHeadingNode,
SerializedListItemNode,
SerializedListNode,
SerializedTableRowNode,
SerializedTableNode,
SerializedTableCellNode,
} from '../../../nodeTypes.js'
import { convertLexicalToPlaintext } from './sync/index.js'
@@ -51,7 +57,83 @@ function paragraphNode(children: DefaultNodeTypes[]): SerializedParagraphNode {
}
}
function rootNode(nodes: DefaultNodeTypes[]): DefaultTypedEditorState {
function headingNode(children: DefaultNodeTypes[]): SerializedHeadingNode {
return {
type: 'heading',
children,
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
tag: 'h1',
version: 1,
}
}
function listItemNode(children: DefaultNodeTypes[]): SerializedListItemNode {
return {
type: 'listitem',
children,
checked: false,
direction: 'ltr',
format: '',
indent: 0,
value: 0,
version: 1,
}
}
function listNode(children: DefaultNodeTypes[]): SerializedListNode {
return {
type: 'list',
children,
direction: 'ltr',
format: '',
indent: 0,
listType: 'bullet',
start: 0,
tag: 'ul',
version: 1,
}
}
function tableNode(children: (DefaultNodeTypes | SerializedTableRowNode)[]): SerializedTableNode {
return {
type: 'table',
children,
direction: 'ltr',
format: '',
indent: 0,
version: 1,
}
}
function tableRowNode(
children: (DefaultNodeTypes | SerializedTableCellNode)[],
): SerializedTableRowNode {
return {
type: 'tablerow',
children,
direction: 'ltr',
format: '',
indent: 0,
version: 1,
}
}
function tableCellNode(children: DefaultNodeTypes[]): SerializedTableCellNode {
return {
type: 'tablecell',
children,
direction: 'ltr',
format: '',
indent: 0,
headerState: 0,
version: 1,
}
}
function rootNode(nodes: (DefaultNodeTypes | SerializedTableNode)[]): DefaultTypedEditorState {
return {
root: {
type: 'root',
@@ -72,7 +154,6 @@ describe('convertLexicalToPlaintext', () => {
data,
})
console.log('plaintext', plaintext)
expect(plaintext).toBe('Basic Text')
})
@@ -111,4 +192,67 @@ describe('convertLexicalToPlaintext', () => {
expect(plaintext).toBe('Basic Text\tNext Line')
})
it('ensure new lines are added between paragraphs', () => {
const data: DefaultTypedEditorState = rootNode([
paragraphNode([textNode('Basic text')]),
paragraphNode([textNode('Next block-node')]),
])
const plaintext = convertLexicalToPlaintext({
data,
})
expect(plaintext).toBe('Basic text\n\nNext block-node')
})
it('ensure new lines are added between heading nodes', () => {
const data: DefaultTypedEditorState = rootNode([
headingNode([textNode('Basic text')]),
headingNode([textNode('Next block-node')]),
])
const plaintext = convertLexicalToPlaintext({
data,
})
expect(plaintext).toBe('Basic text\n\nNext block-node')
})
it('ensure new lines are added between list items and lists', () => {
const data: DefaultTypedEditorState = rootNode([
listNode([listItemNode([textNode('First item')]), listItemNode([textNode('Second item')])]),
listNode([listItemNode([textNode('Next list')])]),
])
const plaintext = convertLexicalToPlaintext({
data,
})
expect(plaintext).toBe('First item\nSecond item\n\nNext list')
})
it('ensure new lines are added between tables, table rows, and table cells', () => {
const data: DefaultTypedEditorState = rootNode([
tableNode([
tableRowNode([
tableCellNode([textNode('Cell 1, Row 1')]),
tableCellNode([textNode('Cell 2, Row 1')]),
]),
tableRowNode([
tableCellNode([textNode('Cell 1, Row 2')]),
tableCellNode([textNode('Cell 2, Row 2')]),
]),
]),
tableNode([tableRowNode([tableCellNode([textNode('Cell in Table 2')])])]),
])
const plaintext = convertLexicalToPlaintext({
data,
})
expect(plaintext).toBe(
'Cell 1, Row 1 | Cell 2, Row 1\nCell 1, Row 2 | Cell 2, Row 2\n\nCell in Table 2',
)
})
})

View File

@@ -86,11 +86,25 @@ export function convertLexicalNodesToPlaintext({
}
} else {
// Default plaintext converter heuristic
if (node.type === 'paragraph') {
if (
node.type === 'paragraph' ||
node.type === 'heading' ||
node.type === 'list' ||
node.type === 'table'
) {
if (plainTextArray?.length) {
// Only add a new line if there is already text in the array
plainTextArray.push('\n\n')
}
} else if (node.type === 'listitem' || node.type === 'tablerow') {
if (plainTextArray?.length) {
// Only add a new line if there is already text in the array
plainTextArray.push('\n')
}
} else if (node.type === 'tablecell') {
if (plainTextArray?.length) {
plainTextArray.push(' | ')
}
} else if (node.type === 'linebreak') {
plainTextArray.push('\n')
} else if (node.type === 'tab') {

View File

@@ -53,22 +53,14 @@ export const RelationshipPlugin: PluginComponent<RelationshipFeatureProps> = ({
if ($isRangeSelection(selection)) {
const relationshipNode = $createRelationshipNode(payload)
// we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert relationship node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(relationshipNode)
const { focus } = selection
const focusNode = focus.getNode()
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
focusNode.getTextContentSize() === 0 &&
focusNode
.getParentOrThrow()
.getChildren()
.filter((node) => $isParagraphNode(node)).length > 1
) {
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
}
}

View File

@@ -53,18 +53,14 @@ export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ client
value: payload.value,
},
})
// we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(uploadNode)
const { focus } = selection
const focusNode = focus.getNode()
// Delete the node it it's an empty paragraph and it has at least one sibling, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
!focusNode.__first &&
(focusNode.__prev || focusNode.__next)
) {
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
}
}

View File

@@ -4,13 +4,7 @@ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary.js'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js'
import {
$createParagraphNode,
$getRoot,
BLUR_COMMAND,
COMMAND_PRIORITY_LOW,
FOCUS_COMMAND,
} from 'lexical'
import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical'
import * as React from 'react'
import { useEffect, useState } from 'react'
@@ -24,6 +18,7 @@ import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/ind
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
import { InsertParagraphAtEndPlugin } from './plugins/InsertParagraphAtEnd/index.js'
import { MarkdownShortcutPlugin } from './plugins/MarkdownShortcut/index.js'
import { NormalizeSelectionPlugin } from './plugins/NormalizeSelection/index.js'
import { SlashMenuPlugin } from './plugins/SlashMenu/index.js'
import { TextPlugin } from './plugins/TextPlugin/index.js'
import { LexicalContentEditable } from './ui/ContentEditable.js'
@@ -112,6 +107,7 @@ export const LexicalEditor: React.FC<
}
ErrorBoundary={LexicalErrorBoundary}
/>
<NormalizeSelectionPlugin />
<InsertParagraphAtEndPlugin />
<DecoratorPlugin />
<TextPlugin features={editorConfig.features} />

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