Compare commits

...

36 Commits

Author SHA1 Message Date
Elliot DeNolf
90686fa50a chore(release): v3.0.0-beta.131 [skip ci] 2024-11-15 15:34:50 -05:00
Paul
26ffbca914 feat: sanitise access endpoint (#7335)
Protects the `/api/access` endpoint behind authentication and sanitizes
the result, making it more secure and significantly smaller. To do this:

1. The `permission` keyword is completely omitted from the result
2. Only _truthy_ access results are returned
3. All nested permissions are consolidated when possible

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
2024-11-15 15:08:06 -05:00
Said Akhrarov
0b9d5a5ae4 docs: fix links in operators table for within and intersects (#9232)
### What?
Fixes links in Queries/Operators table for `within` and `intersects`
operator descriptions.

### Why?
So that they point to the correct destination in the docs.

### How?
Changes to `docs/queries/overview.mdx`

See here:

![image](https://github.com/user-attachments/assets/fc82a6fb-2c7c-4a1e-aa2d-128c9f5e711b)
2024-11-15 21:50:21 +02:00
Patrik
0f7276e3c4 chore: removes examples dir from jobs workflow (#9231) 2024-11-15 14:49:43 -05:00
Patrik
68458787a5 feat!: bumps date-fns to 4.1.0 (#9221) 2024-11-15 14:36:14 -05:00
Sasha
810c29b189 fix!: improve collection / global slugs type-safety in various places (#8311)
**BREAKING:**
Improves type-safety of collection / global slugs by using `CollectionSlug` / `UploadCollectionSlug` and `GlobalSlug` types instead of `string` in these places:
Adds `UploadCollectionSlug` and `TypedUploadCollection` utility types

This also changes how we suggest to add an upload collection to a cloud-storage adapter:
Before:
```ts
azureStorage({
  collections: {
    [Media.slug]: true,
  },
}) 
``` 

After:
```ts
azureStorage({
  collections: {
    media: true,
  },
}) 
```
2024-11-15 19:33:26 +00:00
Dan Ribbens
a5cae077cc fix: duplicate list preferences stored (#9185)
The collection list columns are stored as user preferences to the
payload-preferences collection. Normally one user should never have
duplicate documents with the same key. This is controlled by using an
upsert normally. The collection list does not have a good way to call
upsert and was creating preferences documents every time. This change
makes it so that existing preferences are updated rather than created
with each column change.
2024-11-15 14:22:04 -05:00
Patrik
ba06ce6338 chore(examples): migrates email example to 3.0 [skip-lint] (#9215)
Changes:

- Migrates `email` example project to `3.0` from `2.0`
- Replaces `inline-css` dependency with `juice` package instead.
- Replaces `Handlebars` dependency with `ejs` package instead.

Reason for replacing packages:
- Both `inline-css` & `Handlebars` had issues with Nextjs and its
Webpack bundling i.e does not support `require.extensions`.
- `ejs` & `juice` do not rely on `require.extensions`.
2024-11-15 14:10:24 -05:00
Patrik
7c732bec14 chore: adds email-nodemailer to area-affected dropdown in issue template (#9227) 2024-11-15 12:06:31 -05:00
Dan Ribbens
7c6f41936b feat(db-mongodb)!: update mongoose to 8.8.1 (#9115)
### What?
Upgrades mongoose from 6 to latest `v8.8.1`

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

### Why?
Compatibilty with Mongodb Atlas

### How?
- Updates deps
- Changed ObjectId from bson-objectid to use `new Type.ObjectId` from
mongoose for compatibility (only inside of db-mongodb)
- Internal type adjustments

https://github.com/payloadcms/payload/discussions/9088

BREAKING CHANGES:
All projects with existing data having versions enabled, or relationship or upload fields will want to create the predefined migration that converts all strings to ObjectIDs where needed. This can be created using `payload migrate:create --file @payloadcms/mongodb/relationships-v2-v3`.
For projects making use of the exposed Models from mongoose, review the
upgrade guides from [v6 to
v7](https://mongoosejs.com/docs/7.x/docs/migrating_to_7.html) and [v7 to
v8](https://mongoosejs.com/docs/migrating_to_8.html) and make
adjustments as needed.

---------

Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
2024-11-15 12:03:56 -05:00
Jacob Fletcher
028153f5a4 docs: removes root endpoints from migration guide (#9224) 2024-11-15 11:30:40 -05:00
Jarrod Flesch
2801c41d91 docs: fixes incorrect useField example (#9222) 2024-11-15 10:01:52 -05:00
Germán Jabloñski
82e72fa7f2 feat(richtext-lexical, ui): add icon if link opens in new tab (#9211)
https://github.com/user-attachments/assets/46eebd2f-3965-40be-a7c6-e68446d32398

---------

Co-authored-by: Tylan Davis <hello@tylandavis.com>
2024-11-15 14:55:43 +00:00
Jarrod Flesch
20c899286e chore: export ListHeaderProps (#9217)
Exports ListHeaderProps so others can use them.
2024-11-15 08:23:47 -05:00
Alessio Gravili
729488028b feat(richtext-lexical): add tooltips to toolbar dropdown items (#9218)
Previously, if the dropdown item text is cut off due to length, there
was no way to view the full text.

Now, you can hover:

![CleanShot 2024-11-14 at 18 55
11@2x](https://github.com/user-attachments/assets/b160c172-c78a-4eb5-9fb3-b4ef8aee7eb5)
2024-11-15 02:29:12 +00:00
Jakob Ortmann
e6e0cc2a63 docs: reflect changes to uploadthing config in docs (#9201)
Updates docs to new config specs changed by #8346
2024-11-14 15:22:02 -05:00
Francisco Lourenço
2d2d020c29 feat(db-mongodb): support query options in db update operations (#9191)
The mongodb adapter `updateOne` method accepts an `options` argument
that allows query options to be passed to mongoose. This parameter was
added in https://github.com/payloadcms/payload/pull/8397 to support the
`upsert` operation.

This `options` parameter can also be useful when using the database
adaptor directly without going through the local api. It is true that
the Mongoose models could be used directly in such situations, but the
adapter methods include a lot of useful functionality, like for instance
the sanitization of document and relationship ids, so it is desirable to
be able to use the adapter functions while still being able to provide
mongoose query options (e.g. `{timestamps: false}`).

This PR adds the same options parameter to the other update methods of
the mongodb adapter.
2024-11-14 15:15:03 -05:00
Paul
315b4e566b fix(ui): jumping hasmany uploads when form is submitting or in readonly mode (#9198) 2024-11-14 14:39:31 -05:00
Jacob Fletcher
2d7626c3e9 perf: removes undefined props from rsc requests (#9195)
This is in effort to reduce overall HTML bloat, undefined props still go
through the request as `$undefined` and must be explicitly omitted.
2024-11-14 18:22:42 +00:00
Jarrod Flesch
e75527b0a1 chore: clean up types for HiddenField and WatchCondition (#9208)
### What?
Aligns types for HiddenField and the WatchCondition component with the
rest of the fields. Since path is required when rendering a Field
component, there is no need to keep it optional in the WatchCondition
component.

### Why?
Hidden fields were requiring the `field` property to be passed, but the
only reason it needed it was to allow the path to fallback to name if
path was not passed. But path is required so there is no need for this
anymore.

This makes using the HiddenField simpler now.

### How?
Adjusts type on the HiddenField and the WatchCondition component.
2024-11-14 12:56:17 -05:00
Jacob Fletcher
5482e7ea15 perf: removes i18n.supportedLanguages from client config (#9209)
Similar to https://github.com/payloadcms/payload/pull/9195 but
specifically removing `i18n.supportedLanguages` from the client config.
This is a potentially large object that does not need to be sent through
the network when making RSC requests.
2024-11-14 12:48:00 -05:00
Jarrod Flesch
77c99c2f49 feat!: re-order DefaultCellComponentProps generics (#9207)
### What?
Changes the order of the `DefaultCellComponentProps` generic type,
allowing us to infer the type of cellData when a ClientField type is
passed as the first generic argument. You can override the cellData type
by passing the second generic.

Previously:
```ts
type DefaultCellComponentProps<TCellData = any, TField extends ClientField = ClientField>
```

New:
```ts
type DefaultCellComponentProps<TField extends ClientField = ClientField, TCellData = undefined>
```

### Why?
Changing the ClientField type to be the first argument allows us to
infer the cellData value type based on the type of field.

I could have kept the same signature but the usage would look like:
```ts
// Not very DX friendly
const MyCellComponent<DefaultCellComponentProps<,ClientField>> = () => null
```

### How?
The changes made
[here](https://github.com/payloadcms/payload/compare/chore/beta/simplify-DefaultCellComponentProps?expand=1#diff-24f3c92e546c2be3fed0bab305236bba83001309a7239c20a3e3dbd6f5f71dc6R29-R73)
allow this. You can override the type by passing in the second argument
to the generic.
2024-11-14 12:31:42 -05:00
Elliot DeNolf
5ff1bb366c chore: misc cleanup (#9206)
- Proper error logger usage
- Some no-fallthrough warning cleanup
2024-11-14 11:14:08 -05:00
James Mikrut
e6d04436a8 fix(ui): fixes layout shift when form is submitted (#9184)
Some fields cause layout shift when you submit the form. This PR reduces
that flicker.
2024-11-14 02:57:01 +00:00
Jarrod Flesch
81099cbb04 chore: improve custom server cell types (#9188)
### What?
Exposes DefaultServerCellComponentProps type for custom server cell
components.

### Why?
So users can type their custom server cell components properly.
2024-11-13 17:03:03 -05:00
Sasha
4509c38f4c docs: add within and intersects operators documentation (#9194)
Adds documentation for `within` and `intersects` operators.

#### Querying - within

In order to do query based on whether points are within a specific area
defined in GeoJSON, you can use the `within` operator.
Example:
```ts
const polygon: Point[] = [
  [9.0, 19.0], // bottom-left
  [9.0, 21.0], // top-left
  [11.0, 21.0], // top-right
  [11.0, 19.0], // bottom-right
  [9.0, 19.0], // back to starting point to close the polygon
]

payload.find({
  collection: "points",
  where: {
    point: {
      within: {
        type: 'Polygon',
        coordinates: [polygon],
      },
    },
  },
})
```


#### Querying - intersects

In order to do query based on whether points intersect a specific area
defined in GeoJSON, you can use the `intersects` operator.
Example:
```ts
const polygon: Point[] = [
  [9.0, 19.0], // bottom-left
  [9.0, 21.0], // top-left
  [11.0, 21.0], // top-right
  [11.0, 19.0], // bottom-right
  [9.0, 19.0], // back to starting point to close the polygon
]

payload.find({
  collection: "points",
  where: {
    point: {
      intersects: {
        type: 'Polygon',
        coordinates: [polygon],
      },
    },
  },
})
```
2024-11-13 21:59:22 +00:00
Jarrod Flesch
90e6a4fcd8 docs: note about passing req to local operations (#9192) 2024-11-13 16:49:50 -05:00
Elliot DeNolf
4690cd819a feat(storage-uploadthing)!: upgrade to v7 (#8346)
Upgrade uploadthing to v7

The `options` that can be passed to the plugin now mirror the
`UTApiOptions` of v7.

The most notable change is to pass `token` with
`process.env.UPLOADTHING_TOKEN` instead of `apiKey` with
`process.env.UPLOADTHING_SECRET`.

```diff
options: {
- apiKey: process.env.UPLOADTHING_SECRET,
+ token: process.env.UPLOADTHING_TOKEN,
  acl: 'public-read',
},
2024-11-13 21:27:02 +00:00
Dan Ribbens
afd69c4d54 chore: fix community e2e test (#9187) 2024-11-13 15:47:45 -05:00
Dan Ribbens
de52490a98 chore: fix community test (#9186) 2024-11-13 15:37:44 -05:00
Jarrod Flesch
129fadfd2c fix: wires up abort controller logic for list columns (#9180)
### What?
List column state could become out of sync if toggling columns happened
in rapid succession as seen in CI. Or when using a spotty connection
where responses could come back out of order.

### Why?
State was not being preserved between toggles. Leading to incorrect
columns being toggled on/off.

### How?
Updates internal column state before making the request to the server so
when a future toggle occurs it has up to date state of all columns. Also
introduces an abort controller to prevent the out of order response
issue.
2024-11-13 14:58:49 -05:00
Jacob Fletcher
cea7d58d96 docs: updates and improves migration guide (#9176)
This is a first pass at updating the 3.0 migration guide. While this
makes significant changes and improvements to the guide, it does not
necessarily reflect _all_ of the migration steps needed in their
entirety quite yet. Those will continue to come in.

Key changes:
- Cleans up outdated examples and removes old ones
- Updates code snippets to latest patterns
- Diffs everything for improved readability
2024-11-13 14:29:50 -05:00
Elliot DeNolf
6baff8a3ba chore(release): v3.0.0-beta.130 [skip ci] 2024-11-13 14:18:00 -05:00
James Mikrut
ced79be591 Chore/clean community (#9181)
Cleans up _community test suite
2024-11-13 14:12:19 -05:00
Sasha
5b9cee67c0 fix(db-postgres): create relationship-v2-v3 migration (#9178)
### What?
This command from here:
https://github.com/payloadcms/payload/pull/6339
```sh
payload migrate:create --file @payloadcms/db-postgres/relationships-v2-v3
```
stopped working after db-postgers and drizzle packages were separated 

### How?
Passes correct `dirname` to `getPredefinedMigration`

Additionally, adds support for `.js` files in `getPredefinedMigration`
2024-11-13 19:02:17 +00:00
Jarrod Flesch
bcbca0e44a chore: improves field types (#9172)
### What?
Ensures `path` is required and only present on the fields that expect it
(all fields except row).

Deprecates `useFieldComponents` and `FieldComponentsProvider` and
instead extends the RenderField component to account for all field
types. This also improves type safety within `RenderField`.

### Why?
`path` being optional just adds DX overhead and annoyance. 

### How?
Added `FieldPaths` type which is added to iterable field types. Placed
`path` back onto the ClientFieldBase type.
2024-11-13 13:53:47 -05:00
328 changed files with 12294 additions and 11208 deletions

View File

@@ -39,6 +39,7 @@ body:
- 'db-postgres'
- 'db-sqlite'
- 'db-vercel-postgres'
- 'email-nodemailer'
- 'plugin: cloud'
- 'plugin: cloud-storage'
- 'plugin: form-builder'

View File

@@ -21,10 +21,11 @@ To do so, import the `useField` hook as follows:
```tsx
'use client'
import type { TextFieldClientComponent } from 'payload'
import { useField } from '@payloadcms/ui'
const CustomTextField: React.FC = () => {
const { value, setValue, path } = useField() // highlight-line
export const CustomTextField: TextFieldClientComponent = ({ path }) => {
const { value, setValue } = useField({ path }) // highlight-line
return (
<div>

View File

@@ -8,7 +8,7 @@ keywords: overview, config, configuration, documentation, Content Management Sys
Payload is a _config-based_, code-first CMS and application framework. The Payload Config is central to everything that Payload does, allowing for deep configuration of your application through a simple and intuitive API. The Payload Config is a fully-typed JavaScript object that can be infinitely extended upon.
Everything from your [Database](../database/overview) choice, to the appearance of the [Admin Panel](../admin/overview), is fully controlled through the Payload Config. From here you can define [Fields](../fields/overview), add [Localization](./localization), enable [Authentication](../authentication/overview), configure [Access Control](../access-control/overview), and so much more.
Everything from your [Database](../database/overview) choice to the appearance of the [Admin Panel](../admin/overview) is fully controlled through the Payload Config. From here you can define [Fields](../fields/overview), add [Localization](./localization), enable [Authentication](../authentication/overview), configure [Access Control](../access-control/overview), and so much more.
The Payload Config is a `payload.config.ts` file typically located in the root of your project:
@@ -29,7 +29,7 @@ The Payload Config is strongly typed and ties directly into Payload's TypeScript
## Config Options
To author your Payload Config, first determine which [Database](../database/overview) you'd like to use, then use [Collections](./collections) or [Globals](./globals) to define the schema of your data.
To author your Payload Config, first determine which [Database](../database/overview) you'd like to use, then use [Collections](./collections) or [Globals](./globals) to define the schema of your data through [Fields](../fields/overview).
Here is one of the simplest possible Payload configs:

View File

@@ -86,7 +86,7 @@ _\* This property is passed directly to [react-datepicker](https://github.com/Ha
These properties only affect how the date is displayed in the UI. The full date is always stored in the format `YYYY-MM-DDTHH:mm:ss.SSSZ` (e.g. `1999-01-01T8:00:00.000+05:00`).
`displayFormat` determines how the date is presented in the field **cell**, you can pass any valid (unicode date format)[https://date-fns.org/v2.29.3/docs/format].
`displayFormat` determines how the date is presented in the field **cell**, you can pass any valid (unicode date format)[https://date-fns.org/v4.1.0/docs/format].
`pickerAppearance` sets the appearance of the **react datepicker**, the options available are `dayAndTime`, `dayOnly`, `timeOnly`, and `monthOnly`. By default, the datepicker will display `dayOnly`.

View File

@@ -73,6 +73,59 @@ export const ExampleCollection: CollectionConfig = {
}
```
## Querying
## Querying - near
In order to do query based on the distance to another point, you can use the `near` operator. When querying using the near operator, the returned documents will be sorted by nearest first.
## Querying - within
In order to do query based on whether points are within a specific area defined in GeoJSON, you can use the `within` operator.
Example:
```ts
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
]
payload.find({
collection: "points",
where: {
point: {
within: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
})
```
## Querying - intersects
In order to do query based on whether points intersect a specific area defined in GeoJSON, you can use the `intersects` operator.
Example:
```ts
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
]
payload.find({
collection: "points",
where: {
point: {
intersects: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
})
```

View File

@@ -97,6 +97,17 @@ You can specify more options within the Local API vs. REST or GraphQL due to the
_There are more options available on an operation by operation basis outlined below._
## Transactions
When your database uses transactions you need to thread req through to all local operations. Postgres uses transactions and MongoDB uses transactions when you are using replica sets. Passing req without transactions is still recommended.
```js
const post = await payload.find({
collection: 'posts',
req, // passing req is recommended
})
```
<Banner type="warning">
<strong>Note:</strong>
<br />

File diff suppressed because it is too large Load Diff

View File

@@ -37,21 +37,23 @@ _The exact query syntax will depend on the API you are using, but the concepts a
The following operators are available for use in queries:
| Operator | Description |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `equals` | The value must be exactly equal. |
| `not_equals` | The query will return all documents where the value is not equal. |
| `greater_than` | For numeric or date-based fields. |
| `greater_than_equal` | For numeric or date-based fields. |
| `less_than` | For numeric or date-based fields. |
| `less_than_equal` | For numeric or date-based fields. |
| `like` | Case-insensitive string must be present. If string of words, all words must be present, in any order. |
| `contains` | Must contain the value entered, case-insensitive. |
| `in` | The value must be found within the provided comma-delimited list of values. |
| `not_in` | The value must NOT be within the provided comma-delimited list of values. |
| `all` | The value must contain all values provided in the comma-delimited list. |
| `exists` | Only return documents where the value either exists (`true`) or does not exist (`false`). |
| `near` | For distance related to a [Point Field](../fields/point) comma separated as `<longitude>, <latitude>, <maxDistance in meters (nullable)>, <minDistance in meters (nullable)>`. |
| Operator | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `equals` | The value must be exactly equal. |
| `not_equals` | The query will return all documents where the value is not equal. |
| `greater_than` | For numeric or date-based fields. |
| `greater_than_equal` | For numeric or date-based fields. |
| `less_than` | For numeric or date-based fields. |
| `less_than_equal` | For numeric or date-based fields. |
| `like` | Case-insensitive string must be present. If string of words, all words must be present, in any order. |
| `contains` | Must contain the value entered, case-insensitive. |
| `in` | The value must be found within the provided comma-delimited list of values. |
| `not_in` | The value must NOT be within the provided comma-delimited list of values. |
| `all` | The value must contain all values provided in the comma-delimited list. |
| `exists` | Only return documents where the value either exists (`true`) or does not exist (`false`). |
| `near` | For distance related to a [Point Field](../fields/point) comma separated as `<longitude>, <latitude>, <maxDistance in meters (nullable)>, <minDistance in meters (nullable)>`. |
| `within` | For [Point Fields](../fields/point) to filter documents based on whether points are inside of the given area defined in GeoJSON. [Example](../fields/point#querying-within) |
| `intersects` | For [Point Fields](../fields/point) to filter documents based on whether points intersect with the given area defined in GeoJSON. [Example](../fields/point#querying-intersects) |
<Banner type="success">
<strong>Tip:</strong>

View File

@@ -14,7 +14,7 @@ Payload offers additional storage adapters to handle file uploads. These adapter
| AWS S3 | [`@payloadcms/storage-s3`](https://github.com/payloadcms/payload/tree/beta/packages/storage-s3) |
| Azure | [`@payloadcms/storage-azure`](https://github.com/payloadcms/payload/tree/beta/packages/storage-azure) |
| Google Cloud Storage | [`@payloadcms/storage-gcs`](https://github.com/payloadcms/payload/tree/beta/packages/storage-gcs) |
| Uploadthing | [`@payloadcms/storage-uploadthing`](https://github.com/payloadcms/payload/tree/beta/packages/uploadthing) |
## Vercel Blob Storage
[`@payloadcms/storage-vercel-blob`](https://www.npmjs.com/package/@payloadcms/storage-vercel-blob)
@@ -43,8 +43,8 @@ export default buildConfig({
enabled: true, // Optional, defaults to true
// Specify which collections should use Vercel Blob
collections: {
[Media.slug]: true,
[MediaWithPrefix.slug]: {
media: true,
'media-with-prefix': {
prefix: 'my-prefix',
},
},
@@ -90,8 +90,8 @@ export default buildConfig({
plugins: [
s3Storage({
collections: {
[mediaSlug]: true,
[mediaWithPrefixSlug]: {
media: true,
'media-with-prefix': {
prefix,
},
},
@@ -137,8 +137,8 @@ export default buildConfig({
plugins: [
azureStorage({
collections: {
[mediaSlug]: true,
[mediaWithPrefixSlug]: {
media: true,
'media-with-prefix': {
prefix,
},
},
@@ -186,8 +186,8 @@ export default buildConfig({
plugins: [
gcsStorage({
collections: {
[mediaSlug]: true,
[mediaWithPrefixSlug]: {
media: true,
'media-with-prefix': {
prefix,
},
},
@@ -224,7 +224,7 @@ pnpm add @payloadcms/storage-uploadthing@beta
### Usage
- Configure the `collections` object to specify which collections should use uploadthing. The slug _must_ match one of your existing collection slugs and be an `upload` type.
- Get an API key from Uploadthing and set it as `apiKey` in the `options` object.
- Get a token from Uploadthing and set it as `token` in the `options` object.
- `acl` is optional and defaults to `public-read`.
```ts
@@ -233,10 +233,10 @@ export default buildConfig({
plugins: [
uploadthingStorage({
collections: {
[mediaSlug]: true,
media: true,
},
options: {
apiKey: process.env.UPLOADTHING_SECRET,
token: process.env.UPLOADTHING_TOKEN,
acl: 'public-read',
},
}),
@@ -248,7 +248,7 @@ export default buildConfig({
| Option | Description | Default |
| ---------------- | ----------------------------------------------- | ------------- |
| `apiKey` | API key from Uploadthing. Required. | |
| `token` | Token from Uploadthing. Required. | |
| `acl` | Access control list for files that are uploaded | `public-read` |
| `logLevel` | Log level for Uploadthing | `info` |
| `fetch` | Custom fetch function | `fetch` |

View File

@@ -1,4 +1,5 @@
MONGODB_URI=mongodb://127.0.0.1/payload-example-email
PAYLOAD_SECRET=
DATABASE_URI=mongodb://127.0.0.1/payload-example-email
NODE_ENV=development
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000
PAYLOAD_SECRET=PAYLOAD_EMAIL_EXAMPLE_SECRET_KEY
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000

View File

@@ -1,7 +1,4 @@
module.exports = {
root: true,
extends: ['@payloadcms'],
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
},
}

24
examples/email/.swcrc Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}

View File

@@ -7,30 +7,31 @@ This example demonstrates how to integrate email functionality into Payload.
To spin up this example locally, follow these steps:
1. Clone this repo
2. `cd` into this directory and run `yarn` or `npm install`
3. `cp .env.example .env` to copy the example environment variables
2. `cp .env.example .env` to copy the example environment variables
3. `pnpm install && pnpm dev` to install dependencies and start the dev server
4. `yarn dev` or `npm run dev` to start the server and seed the database
5. `open http://localhost:8000/admin` to access the admin panel
5. open `http://localhost:3000/admin` to access the admin panel
6. Create your first user
## How it works
Payload utilizes [NodeMailer](https://nodemailer.com/about/) for email functionality. Once you add your email configuration to `payload.init()`, you send email from anywhere in your application just by calling `payload.sendEmail({})`.
Email functionality in Payload is configured using adapters. The recommended adapter for most use cases is the [@payloadcms/email-nodemailer](https://www.npmjs.com/package/@payloadcms/email-nodemailer) package.
1. Navigate to `src/server.ts` - this is where your email config gets passed to Payload
2. Open `src/email/transport.ts` - here we are defining the email config. You can use an env variable to switch between the mock email transport and live email service.
To enable email, pass your adapter configuration to the `email` property in the Payload Config. This allows Payload to send auth-related emails for password resets, new user verifications, and other email needs.
1. In the Payload Config file, add your email adapter to the `email` property. For example, the `@payloadcms/email-nodemailer` adapter can be configured for SMTP, SendGrid, or other supported transports. During development, if no configuration is provided, Payload will use a mock service via [ethereal.email](ethereal.email).
Now we can start sending email!
3. Go to `src/collections/Newsletter.ts` - with an `afterChange` hook, we are sending an email when a new user signs up for the newsletter
2. Go to `src/collections/Newsletter.ts` - with an `afterChange` hook, we are sending an email when a new user signs up for the newsletter
Let's not forget our authentication emails...
4. Auth-enabled collections have built-in options to verify the user and reset the user password. Open `src/collections/Users.ts` and see how we customize these emails.
3. Auth-enabled collections have built-in options to verify the user and reset the user password. Open `src/collections/Users.ts` and see how we customize these emails.
Speaking of customization...
5. Take a look at `src/email/generateEmailHTML` and how it compiles a custom template when sending email. You change this to any HTML template of your choosing.
4. Take a look at `src/email/generateEmailHTML` and how it compiles a custom template when sending email. You change this to any HTML template of your choosing.
That's all you need, now you can go ahead and test out this repo by creating a new `user` or `newsletter-signup` and see the email integration in action.
@@ -40,10 +41,10 @@ To spin up this example locally, follow the [Quick Start](#quick-start).
## Production
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
To run Payload in production, you need to build and start the Admin panel. To do so, follow these steps:
1. First invoke the `payload build` script by running `yarn build` or `npm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
1. Then run `yarn serve` or `npm run serve` to run Node in production and serve Payload from the `./build` directory.
1. Invoke the `next build` script by running `pnpm build` or `npm run build` in your project root. This creates a `.next` directory with a production-ready admin bundle.
1. Finally run `pnpm start` or `npm run start` to run Node in production and serve Payload from the `.build` directory.
### Deployment

5
examples/email/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -0,0 +1,8 @@
import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your Next.js config here
}
export default withPayload(nextConfig)

View File

@@ -1,5 +0,0 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts -- -I",
"stdin": false
}

View File

@@ -1,35 +1,57 @@
{
"name": "payload-example-email",
"description": "Payload Email integration example.",
"version": "1.0.0",
"main": "dist/server.js",
"description": "Payload Email integration example.",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
"lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src"
"_dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"dev": "cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:schema": "payload-graphql generate:schema",
"generate:types": "payload generate:types",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/db-mongodb": "beta",
"@payloadcms/email-nodemailer": "beta",
"@payloadcms/next": "beta",
"@payloadcms/richtext-lexical": "beta",
"@payloadcms/ui": "beta",
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "latest",
"handlebars": "^4.7.7",
"inline-css": "^4.0.2"
"ejs": "3.1.10",
"graphql": "^16.9.0",
"juice": "11.0.0",
"next": "15.0.0",
"payload": "beta",
"react": "19.0.0-rc-65a56d0e-20241020",
"react-dom": "19.0.0-rc-65a56d0e-20241020"
},
"devDependencies": {
"@types/express": "^4.17.9",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.19.0",
"nodemon": "^2.0.6",
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
"@payloadcms/graphql": "beta",
"@swc/core": "^1.6.13",
"@types/ejs": "^3.1.5",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"eslint": "^8.57.0",
"eslint-config-next": "15.0.0",
"tsx": "^4.16.2",
"typescript": "5.5.2"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
}
},
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
}
}

6932
examples/email/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })
export default NotFound

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })
export default Page

View File

@@ -0,0 +1 @@
export const importMap = {}

View File

@@ -0,0 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,32 @@
import type { ServerFunctionClient } from 'payload'
import '@payloadcms/next/css'
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

View File

@@ -1,28 +1,12 @@
import type { CollectionConfig } from 'payload/types'
import generateEmailHTML from '../email/generateEmailHTML'
import type { CollectionConfig } from 'payload'
const Newsletter: CollectionConfig = {
import { generateEmailHTML } from '../email/generateEmailHTML'
export const Newsletter: CollectionConfig = {
slug: 'newsletter-signups',
admin: {
defaultColumns: ['name', 'email'],
},
hooks: {
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
req.payload.sendEmail({
to: doc.email,
from: 'sender@example.com',
subject: 'Thanks for signing up!',
html: await generateEmailHTML({
headline: 'Welcome to the newsletter!',
content: `<p>${doc.name ? `Hi ${doc.name}!` : 'Hi!'} We'll be in touch soon...</p>`,
}),
})
}
},
],
},
fields: [
{
name: 'name',
@@ -34,6 +18,25 @@ const Newsletter: CollectionConfig = {
required: true,
},
],
hooks: {
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
req.payload
.sendEmail({
from: 'sender@example.com',
html: await generateEmailHTML({
content: `<p>${doc.name ? `Hi ${doc.name}!` : 'Hi!'} We'll be in touch soon...</p>`,
headline: 'Welcome to the newsletter!',
}),
subject: 'Thanks for signing up!',
to: doc.email,
})
.catch((error) => {
console.error('Error sending email:', error)
})
}
},
],
},
}
export default Newsletter

View File

@@ -1,23 +1,23 @@
import type { CollectionConfig } from 'payload/types'
import type { CollectionConfig } from 'payload'
import generateForgotPasswordEmail from '../email/generateForgotPasswordEmail'
import generateVerificationEmail from '../email/generateVerificationEmail'
import { generateForgotPasswordEmail } from '../email/generateForgotPasswordEmail'
import { generateVerificationEmail } from '../email/generateVerificationEmail'
const Users: CollectionConfig = {
export const Users: CollectionConfig = {
slug: 'users',
auth: {
verify: {
generateEmailSubject: () => 'Verify your email',
generateEmailHTML: generateVerificationEmail,
},
forgotPassword: {
generateEmailSubject: () => 'Reset your password',
generateEmailHTML: generateForgotPasswordEmail,
},
},
admin: {
useAsTitle: 'email',
},
auth: {
forgotPassword: {
generateEmailHTML: generateForgotPasswordEmail,
generateEmailSubject: () => 'Reset your password',
},
verify: {
generateEmailHTML: generateVerificationEmail,
generateEmailSubject: () => 'Verify your email',
},
},
fields: [
{
name: 'name',
@@ -25,5 +25,3 @@ const Users: CollectionConfig = {
},
],
}
export default Users

View File

@@ -1,24 +1,17 @@
import ejs from 'ejs'
import fs from 'fs'
import Handlebars from 'handlebars'
import inlineCSS from 'inline-css'
import juice from 'juice'
import path from 'path'
const template = fs.readFileSync
? fs.readFileSync(path.join(__dirname, './template.html'), 'utf8')
: ''
export const generateEmailHTML = async (data: any): Promise<string> => {
const templatePath = path.join(process.cwd(), 'src/email/template.ejs')
const templateContent = fs.readFileSync(templatePath, 'utf8')
// Compile the template
const getHTML = Handlebars.compile(template)
// Compile and render the template with EJS
const preInlinedCSS = ejs.render(templateContent, { ...data, cta: data.cta || {} })
const generateEmailHTML = async (data): Promise<string> => {
const preInlinedCSS = getHTML(data)
// Inline CSS
const html = juice(preInlinedCSS)
const html = await inlineCSS(preInlinedCSS, {
url: ' ',
removeStyleTags: false,
})
return html
return Promise.resolve(html)
}
export default generateEmailHTML

View File

@@ -1,13 +1,24 @@
import generateEmailHTML from './generateEmailHTML'
import type { PayloadRequest } from 'payload'
const generateForgotPasswordEmail = async ({ token }): Promise<string> =>
generateEmailHTML({
headline: 'Locked out?',
import { generateEmailHTML } from './generateEmailHTML'
type ForgotPasswordEmailArgs =
| {
req?: PayloadRequest
token?: string
user?: any
}
| undefined
export const generateForgotPasswordEmail = async (
args: ForgotPasswordEmailArgs,
): Promise<string> => {
return generateEmailHTML({
content: '<p>Let&apos;s get you back in.</p>',
cta: {
buttonLabel: 'Reset your password',
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/reset-password?token=${token}`,
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/reset-password?token=${args?.token}`,
},
headline: 'Locked out?',
})
export default generateForgotPasswordEmail
}

View File

@@ -1,16 +1,26 @@
import generateEmailHTML from './generateEmailHTML'
import { generateEmailHTML } from './generateEmailHTML'
const generateVerificationEmail = async (args): Promise<string> => {
const { user, token } = args
type User = {
email: string
name?: string
}
type GenerateVerificationEmailArgs = {
token: string
user: User
}
export const generateVerificationEmail = async (
args: GenerateVerificationEmailArgs,
): Promise<string> => {
const { token, user } = args
return generateEmailHTML({
headline: 'Verify your account',
content: `<p>Hi${user.name ? ' ' + user.name : ''}! Validate your account by clicking the button below.</p>`,
cta: {
buttonLabel: 'Verify',
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/verify?token=${token}&email=${user.email}`,
},
headline: 'Verify your account',
})
}
export default generateVerificationEmail

View File

@@ -0,0 +1,327 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style type="text/css">
body,
html {
margin: 0;
padding: 0;
}
body,
html,
.bg {
height: 100%;
}
body,
h1,
h2,
h3,
h4,
p,
em,
strong {
font-family: sans-serif;
}
body {
font-size: 15px;
color: #333333;
}
a {
color: #333333;
outline: 0;
text-decoration: underline;
}
a img {
border: 0;
outline: 0;
}
img {
max-width: 100%;
height: auto;
vertical-align: top;
}
h1,
h2,
h3,
h4,
h5 {
font-weight: 900;
line-height: 1.25;
}
h1 {
font-size: 40px;
color: #333333;
margin: 0 0 25px 0;
}
h2 {
color: #333333;
margin: 0 0 25px 0;
font-size: 30px;
line-height: 30px;
}
h3 {
font-size: 25px;
color: #333333;
margin: 0 0 25px 0;
}
h4 {
font-size: 20px;
color: #333333;
margin: 0 0 15px 0;
line-height: 30px;
}
h5 {
color: #333333;
font-size: 17px;
font-weight: 900;
margin: 0 0 15px;
}
table {
border-collapse: collapse;
}
p,
td {
font-size: 14px;
line-height: 25px;
color: #333333;
}
p {
margin: 0 0 25px;
}
ul {
padding-left: 15px;
margin-left: 15px;
font-size: 14px;
line-height: 25px;
margin-bottom: 25px;
}
li {
font-size: 14px;
line-height: 25px;
color: #333333;
}
table.hr td {
font-size: 0;
line-height: 2px;
}
.white {
color: white;
}
/********************************
MAIN
********************************/
.main {
background: white;
}
/********************************
MAX WIDTHS
********************************/
.max-width {
max-width: 800px;
width: 94%;
margin: 0 3%;
}
/********************************
REUSABLES
********************************/
.padding {
padding: 60px;
}
.center {
text-align: center;
}
.no-border {
border: 0;
outline: none;
text-decoration: none;
}
.no-margin {
margin: 0;
}
.spacer {
line-height: 45px;
height: 45px;
}
/********************************
PANELS
********************************/
.panel {
width: 100%;
}
@media screen and (max-width: 800px) {
h1 {
font-size: 24px !important;
margin: 0 0 20px 0 !important;
}
h2 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h3 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h4 {
font-size: 18px !important;
margin: 0 0 15px 0 !important;
}
h5 {
font-size: 15px !important;
margin: 0 0 10px !important;
}
.max-width {
width: 90% !important;
margin: 0 5% !important;
}
td.padding {
padding: 30px !important;
}
td.padding-vert {
padding-top: 20px !important;
padding-bottom: 20px !important;
}
td.padding-horiz {
padding-left: 20px !important;
padding-right: 20px !important;
}
.spacer {
line-height: 20px !important;
height: 20px !important;
}
}
</style>
</head>
<body>
<div style="background-color: #f3f3f3; height: 100%">
<table height="100%" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f3f3f3">
<tr>
<td valign="top" align="left">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center" valign="top">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center">
<table class="max-width" cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td class="padding main">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td>
<!-- LOGO -->
<a href="https://payloadcms.com/" target="_blank">
<img src="https://payloadcms.com/images/logo-dark.png" width="150"
height="auto" />
</a>
</td>
</tr>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td>
<!-- HEADLINE -->
<h1 style="margin: 0 0 30px"><%= headline %></h1>
</td>
</tr>
<tr>
<td>
<!-- CONTENT -->
<%- content %>
<!-- CTA -->
<% if (cta) { %>
<div>
<a href="<%= cta.url %>" style="
background-color: #222222;
border-radius: 4px;
color: #ffffff;
display: inline-block;
font-family: sans-serif;
font-size: 13px;
font-weight: bold;
line-height: 60px;
text-align: center;
text-decoration: none;
width: 200px;
-webkit-text-size-adjust: none;
">
<%= cta.buttonLabel %>
</a>
</div>
<% } %>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -1,345 +0,0 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style type="text/css">
body,
html {
margin: 0;
padding: 0;
}
body,
html,
.bg {
height: 100%;
}
body,
h1,
h2,
h3,
h4,
p,
em,
strong {
font-family: sans-serif;
}
body {
font-size: 15px;
color: #333333;
}
a {
color: #333333;
outline: 0;
text-decoration: underline;
}
a img {
border: 0;
outline: 0;
}
img {
max-width: 100%;
height: auto;
vertical-align: top;
}
h1,
h2,
h3,
h4,
h5 {
font-weight: 900;
line-height: 1.25;
}
h1 {
font-size: 40px;
color: #333333;
margin: 0 0 25px 0;
}
h2 {
color: #333333;
margin: 0 0 25px 0;
font-size: 30px;
line-height: 30px;
}
h3 {
font-size: 25px;
color: #333333;
margin: 0 0 25px 0;
}
h4 {
font-size: 20px;
color: #333333;
margin: 0 0 15px 0;
line-height: 30px;
}
h5 {
color: #333333;
font-size: 17px;
font-weight: 900;
margin: 0 0 15px;
}
table {
border-collapse: collapse;
}
p,
td {
font-size: 14px;
line-height: 25px;
color: #333333;
}
p {
margin: 0 0 25px;
}
ul {
padding-left: 15px;
margin-left: 15px;
font-size: 14px;
line-height: 25px;
margin-bottom: 25px;
}
li {
font-size: 14px;
line-height: 25px;
color: #333333;
}
table.hr td {
font-size: 0;
line-height: 2px;
}
.white {
color: white;
}
/********************************
MAIN
********************************/
.main {
background: white;
}
/********************************
MAX WIDTHS
********************************/
.max-width {
max-width: 800px;
width: 94%;
margin: 0 3%;
}
/********************************
REUSABLES
********************************/
.padding {
padding: 60px;
}
.center {
text-align: center;
}
.no-border {
border: 0;
outline: none;
text-decoration: none;
}
.no-margin {
margin: 0;
}
.spacer {
line-height: 45px;
height: 45px;
}
/********************************
PANELS
********************************/
.panel {
width: 100%;
}
@media screen and (max-width: 800px) {
h1 {
font-size: 24px !important;
margin: 0 0 20px 0 !important;
}
h2 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h3 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h4 {
font-size: 18px !important;
margin: 0 0 15px 0 !important;
}
h5 {
font-size: 15px !important;
margin: 0 0 10px !important;
}
.max-width {
width: 90% !important;
margin: 0 5% !important;
}
td.padding {
padding: 30px !important;
}
td.padding-vert {
padding-top: 20px !important;
padding-bottom: 20px !important;
}
td.padding-horiz {
padding-left: 20px !important;
padding-right: 20px !important;
}
.spacer {
line-height: 20px !important;
height: 20px !important;
}
}
</style>
</head>
<body>
<div style="background-color: #f3f3f3; height: 100%">
<table
height="100%"
width="100%"
cellpadding="0"
cellspacing="0"
border="0"
bgcolor="#f3f3f3"
style="background-color: #f3f3f3"
>
<tr>
<td valign="top" align="left">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center" valign="top">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center">
<table
class="max-width"
cellpadding="0"
cellspacing="0"
border="0"
width="100%"
style="width: 100%"
>
<tbody>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td class="padding main">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td>
<!-- LOGO -->
<a href="https://payloadcms.com/" target="_blank">
<img
src="https://payloadcms.com/images/logo-dark.png"
width="150"
height="auto"
/>
</a>
</td>
</tr>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td>
<!-- HEADLINE -->
<h1 style="margin: 0 0 30px">{{headline}}</h1>
</td>
</tr>
<tr>
<td>
<!-- CONTENT -->
{{{content}}}
<!-- CTA -->
{{#if cta}}
<div>
<a
href="{{cta.url}}"
style="
background-color: #222222;
border-radius: 4px;
color: #ffffff;
display: inline-block;
font-family: sans-serif;
font-size: 13px;
font-weight: bold;
line-height: 60px;
text-align: center;
text-decoration: none;
width: 200px;
-webkit-text-size-adjust: none;
"
>
{{cta.buttonLabel}}
</a>
</div>
{{/if}}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -1,19 +0,0 @@
let email
if (process.env.NODE_ENV === 'production') {
email = {
fromName: 'Payload',
fromAddress: 'info@payloadcms.com',
transportOptions: {
// Configure a custom transport here
},
}
} else {
email = {
fromName: 'Ethereal Email',
fromAddress: 'example@ethereal.com',
logMockCredentials: true,
}
}
export default email

View File

@@ -1 +0,0 @@
export default {}

View File

@@ -1,4 +1,5 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
@@ -6,30 +7,213 @@
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
'newsletter-signups': NewsletterSignup;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
'newsletter-signups': NewsletterSignupsSelect<false> | NewsletterSignupsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs?: {
tasks: unknown;
workflows?: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "newsletter-signups".
*/
export interface NewsletterSignup {
id: string;
name?: string;
name?: string | null;
email: string;
createdAt: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
name?: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
_verified?: boolean;
_verificationToken?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
name?: string | null;
updatedAt: string;
password?: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
_verified?: boolean | null;
_verificationToken?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'newsletter-signups';
value: string | NewsletterSignup;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "newsletter-signups_select".
*/
export interface NewsletterSignupsSelect<T extends boolean = true> {
name?: T;
email?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
_verified?: T;
_verificationToken?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@@ -1,42 +1,37 @@
import dotenv from 'dotenv'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload/config'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import Users from './collections/Users'
import Newsletter from './collections/Newsletter'
import { Newsletter } from './collections/Newsletter'
import { Users } from './collections/Users'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
const mockModulePath = path.resolve(__dirname, './emptyModule.js')
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// eslint-disable-next-line no-restricted-exports
export default buildConfig({
admin: {
webpack: (config) => ({
...config,
resolve: {
...config?.resolve,
alias: [
'fs',
'handlebars',
'inline-css',
path.resolve(__dirname, './email/transport'),
path.resolve(__dirname, './email/generateEmailHTML'),
path.resolve(__dirname, './email/generateForgotPasswordEmail'),
path.resolve(__dirname, './email/generateVerificationEmail'),
].reduce(
(aliases, importPath) => ({
...aliases,
[importPath]: mockModulePath,
}),
config.resolve.alias,
),
},
}),
importMap: {
baseDir: path.resolve(dirname),
},
user: Users.slug,
},
collections: [Newsletter, Users],
db: mongooseAdapter({
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor({}),
// For example use case, we are passing nothing to nodemailerAdapter
// This will default to using etherial.email
email: nodemailerAdapter(),
graphQL: {
schemaOutputFile: path.resolve(dirname, 'generated-schema.graphql'),
},
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -1,30 +0,0 @@
import express from 'express'
import path from 'path'
import payload from 'payload'
import email from './email/transport'
require('dotenv').config({
path: path.resolve(__dirname, '../.env'),
})
const app = express()
app.get('/', (_, res) => {
res.redirect('/admin')
})
const start = async (): Promise<void> => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
email,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
},
})
app.listen(8000)
}
start()

View File

@@ -1,24 +1,48 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": ".",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"sourceMap": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"payload/generated-types": ["./src/payload-types.ts"],
"node_modules/*": ["./node_modules/*"]
}
"@/*": [
"./src/*"
],
"@payload-config": [
"src/payload.config.ts"
],
"@payload-types": [
"src/payload-types.ts"
]
},
"target": "ES2017"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "build"],
"ts-node": {
"transpileOnly": true
}
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"src/mocks/emptyObject.js"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.129",
"version": "3.0.0-beta.131",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.129",
"version": "3.0.0-beta.131",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -71,7 +71,7 @@ const vercelBlobStorageReplacement: StorageAdapterReplacement = {
configReplacement: [
' vercelBlobStorage({',
' collections: {',
' [Media.slug]: true,',
' media: true,',
' },',
" token: process.env.BLOB_READ_WRITE_TOKEN || '',",
' }),',

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.129",
"version": "3.0.0-beta.131",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -23,13 +23,17 @@
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./migration-utils": {
"import": "./src/exports/migration-utils.ts",
"types": "./src/exports/migration-utils.ts",
"default": "./src/exports/migration-utils.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist",
"mock.js",
"predefinedMigrations"
],
"scripts": {
@@ -42,18 +46,17 @@
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"dependencies": {
"bson-objectid": "2.0.4",
"http-status": "1.6.2",
"mongoose": "6.12.3",
"mongoose-aggregate-paginate-v2": "1.0.6",
"mongoose-paginate-v2": "1.7.22",
"mongoose": "8.8.1",
"mongoose-aggregate-paginate-v2": "1.1.2",
"mongoose-paginate-v2": "1.8.5",
"prompts": "2.4.2",
"uuid": "10.0.0"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/mongoose-aggregate-paginate-v2": "1.0.6",
"mongodb": "4.17.1",
"@types/mongoose-aggregate-paginate-v2": "1.0.12",
"mongodb": "6.10.0",
"mongodb-memory-server": "^9",
"payload": "workspace:*"
},
@@ -66,6 +69,11 @@
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./migration-utils": {
"import": "./dist/exports/migration-utils.js",
"types": "./dist/exports/migration-utils.d.ts",
"default": "./dist/exports/migration-utils.js"
}
},
"main": "./dist/index.js",

View File

@@ -60,14 +60,7 @@ export const connect: Connect = async function connect(
if (this.ensureIndexes) {
await Promise.all(
this.payload.config.collections.map(async (coll) => {
await new Promise((resolve, reject) => {
this.collections[coll.slug]?.ensureIndexes(function (err) {
if (err) {
reject(err)
}
resolve(true)
})
})
await this.collections[coll.slug]?.ensureIndexes()
}),
)
}

View File

@@ -1,4 +1,4 @@
import type { QueryOptions } from 'mongoose'
import type { CountOptions } from 'mongodb'
import type { Count, PayloadRequest } from 'payload'
import { flattenWhereToOperators } from 'payload'
@@ -12,7 +12,7 @@ export const count: Count = async function count(
{ collection, locale, req = {} as PayloadRequest, where },
) {
const Model = this.collections[collection]
const options: QueryOptions = await withSession(this, req)
const options: CountOptions = await withSession(this, req)
let hasNearConstraint = false
@@ -40,7 +40,12 @@ export const count: Count = async function count(
}
}
const result = await Model.countDocuments(query, options)
let result: number
if (useEstimatedCount) {
result = await Model.estimatedDocumentCount({ session: options.session })
} else {
result = await Model.countDocuments(query, options)
}
return {
totalDocs: result,

View File

@@ -1,4 +1,4 @@
import type { QueryOptions } from 'mongoose'
import type { CountOptions } from 'mongodb'
import type { CountGlobalVersions, PayloadRequest } from 'payload'
import { flattenWhereToOperators } from 'payload'
@@ -12,7 +12,7 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob
{ global, locale, req = {} as PayloadRequest, where },
) {
const Model = this.versions[global]
const options: QueryOptions = await withSession(this, req)
const options: CountOptions = await withSession(this, req)
let hasNearConstraint = false
@@ -40,7 +40,12 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob
}
}
const result = await Model.countDocuments(query, options)
let result: number
if (useEstimatedCount) {
result = await Model.estimatedDocumentCount({ session: options.session })
} else {
result = await Model.countDocuments(query, options)
}
return {
totalDocs: result,

View File

@@ -1,4 +1,4 @@
import type { QueryOptions } from 'mongoose'
import type { CountOptions } from 'mongodb'
import type { CountVersions, PayloadRequest } from 'payload'
import { flattenWhereToOperators } from 'payload'
@@ -12,7 +12,7 @@ export const countVersions: CountVersions = async function countVersions(
{ collection, locale, req = {} as PayloadRequest, where },
) {
const Model = this.versions[collection]
const options: QueryOptions = await withSession(this, req)
const options: CountOptions = await withSession(this, req)
let hasNearConstraint = false
@@ -40,7 +40,12 @@ export const countVersions: CountVersions = async function countVersions(
}
}
const result = await Model.countDocuments(query, options)
let result: number
if (useEstimatedCount) {
result = await Model.estimatedDocumentCount({ session: options.session })
} else {
result = await Model.countDocuments(query, options)
}
return {
totalDocs: result,

View File

@@ -1,4 +1,4 @@
import mongoose from 'mongoose'
import { Types } from 'mongoose'
import {
buildVersionCollectionFields,
type CreateVersion,
@@ -57,7 +57,7 @@ export const createVersion: CreateVersion = async function createVersion(
},
],
}
if (data.parent instanceof mongoose.Types.ObjectId) {
if (data.parent instanceof Types.ObjectId) {
parentQuery.$or.push({
parent: {
$eq: data.parent.toString(),

View File

@@ -0,0 +1,2 @@
export { migrateRelationshipsV2_V3 } from '../predefinedMigrations/migrateRelationshipsV2_V3.js'
export { migrateVersionsV1_V2 } from '../predefinedMigrations/migrateVersionsV1_V2.js'

View File

@@ -58,7 +58,6 @@ export const find: Find = async function find(
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
const paginationOptions: PaginateOptions = {
forceCountFn: hasNearConstraint,
lean: true,
leanWithId: true,
options,

View File

@@ -64,7 +64,6 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
const paginationOptions: PaginateOptions = {
forceCountFn: hasNearConstraint,
lean: true,
leanWithId: true,
limit,

View File

@@ -60,7 +60,6 @@ export const findVersions: FindVersions = async function findVersions(
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
const paginationOptions: PaginateOptions = {
forceCountFn: hasNearConstraint,
lean: true,
leanWithId: true,
limit,

View File

@@ -1,7 +1,17 @@
import type { CollationOptions, TransactionOptions } from 'mongodb'
import type { MongoMemoryReplSet } from 'mongodb-memory-server'
import type { ClientSession, Connection, ConnectOptions, QueryOptions } from 'mongoose'
import type { BaseDatabaseAdapter, DatabaseAdapterObj, Payload, UpdateOneArgs } from 'payload'
import type {
BaseDatabaseAdapter,
DatabaseAdapterObj,
Payload,
TypeWithID,
TypeWithVersion,
UpdateGlobalArgs,
UpdateGlobalVersionArgs,
UpdateOneArgs,
UpdateVersionArgs,
} from 'payload'
import fs from 'fs'
import mongoose from 'mongoose'
@@ -135,7 +145,16 @@ declare module 'payload' {
}[]
sessions: Record<number | string, ClientSession>
transactionOptions: TransactionOptions
updateGlobal: <T extends Record<string, unknown>>(
args: { options?: QueryOptions } & UpdateGlobalArgs<T>,
) => Promise<T>
updateGlobalVersion: <T extends TypeWithID = TypeWithID>(
args: { options?: QueryOptions } & UpdateGlobalVersionArgs<T>,
) => Promise<TypeWithVersion<T>>
updateOne: (args: { options?: QueryOptions } & UpdateOneArgs) => Promise<Document>
updateVersion: <T extends TypeWithID = TypeWithID>(
args: { options?: QueryOptions } & UpdateVersionArgs<T>,
) => Promise<TypeWithVersion<T>>
versions: {
[slug: string]: CollectionModel
}

View File

@@ -17,14 +17,14 @@ import { getDBName } from './utilities/getDBName.js'
export const init: Init = function init(this: MongooseAdapter) {
this.payload.config.collections.forEach((collection: SanitizedCollectionConfig) => {
const schema = buildCollectionSchema(collection, this.payload.config)
const schema = buildCollectionSchema(collection, this.payload)
if (collection.versions) {
const versionModelName = getDBName({ config: collection, versions: true })
const versionCollectionFields = buildVersionCollectionFields(this.payload.config, collection)
const versionSchema = buildSchema(this.payload.config, versionCollectionFields, {
const versionSchema = buildSchema(this.payload, versionCollectionFields, {
disableUnique: true,
draftsEnabled: true,
indexSortableFields: this.payload.config.indexSortableFields,
@@ -66,7 +66,7 @@ export const init: Init = function init(this: MongooseAdapter) {
) as CollectionModel
})
this.globals = buildGlobalModel(this.payload.config)
this.globals = buildGlobalModel(this.payload)
this.payload.config.globals.forEach((global) => {
if (global.versions) {
@@ -74,7 +74,7 @@ export const init: Init = function init(this: MongooseAdapter) {
const versionGlobalFields = buildVersionGlobalFields(this.payload.config, global)
const versionSchema = buildSchema(this.payload.config, versionGlobalFields, {
const versionSchema = buildSchema(this.payload, versionGlobalFields, {
disableUnique: true,
draftsEnabled: true,
indexSortableFields: this.payload.config.indexSortableFields,

View File

@@ -1,5 +1,5 @@
import type { PaginateOptions, Schema } from 'mongoose'
import type { SanitizedCollectionConfig, SanitizedConfig } from 'payload'
import type { Payload, SanitizedCollectionConfig } from 'payload'
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
import paginate from 'mongoose-paginate-v2'
@@ -9,12 +9,12 @@ import { buildSchema } from './buildSchema.js'
export const buildCollectionSchema = (
collection: SanitizedCollectionConfig,
config: SanitizedConfig,
payload: Payload,
schemaOptions = {},
): Schema => {
const schema = buildSchema(config, collection.fields, {
const schema = buildSchema(payload, collection.fields, {
draftsEnabled: Boolean(typeof collection?.versions === 'object' && collection.versions.drafts),
indexSortableFields: config.indexSortableFields,
indexSortableFields: payload.config.indexSortableFields,
options: {
minimize: false,
timestamps: collection.timestamps !== false,
@@ -34,7 +34,7 @@ export const buildCollectionSchema = (
schema.index(indexDefinition, { unique: true })
}
if (config.indexSortableFields && collection.timestamps !== false) {
if (payload.config.indexSortableFields && collection.timestamps !== false) {
schema.index({ updatedAt: 1 })
schema.index({ createdAt: 1 })
}

View File

@@ -1,4 +1,4 @@
import type { SanitizedConfig } from 'payload'
import type { Payload } from 'payload'
import mongoose from 'mongoose'
@@ -7,8 +7,8 @@ import type { GlobalModel } from '../types.js'
import { getBuildQueryPlugin } from '../queries/buildQuery.js'
import { buildSchema } from './buildSchema.js'
export const buildGlobalModel = (config: SanitizedConfig): GlobalModel | null => {
if (config.globals && config.globals.length > 0) {
export const buildGlobalModel = (payload: Payload): GlobalModel | null => {
if (payload.config.globals && payload.config.globals.length > 0) {
const globalsSchema = new mongoose.Schema(
{},
{ discriminatorKey: 'globalType', minimize: false, timestamps: true },
@@ -18,8 +18,8 @@ export const buildGlobalModel = (config: SanitizedConfig): GlobalModel | null =>
const Globals = mongoose.model('globals', globalsSchema, 'globals') as unknown as GlobalModel
Object.values(config.globals).forEach((globalConfig) => {
const globalSchema = buildSchema(config, globalConfig.fields, {
Object.values(payload.config.globals).forEach((globalConfig) => {
const globalSchema = buildSchema(payload, globalConfig.fields, {
options: {
minimize: false,
},

View File

@@ -1,35 +1,35 @@
import type { IndexOptions, Schema, SchemaOptions, SchemaTypeOptions } from 'mongoose'
import type {
ArrayField,
Block,
BlocksField,
CheckboxField,
CodeField,
CollapsibleField,
DateField,
EmailField,
Field,
FieldAffectingData,
GroupField,
JSONField,
NonPresentationalField,
NumberField,
PointField,
RadioField,
RelationshipField,
RichTextField,
RowField,
SanitizedConfig,
SanitizedLocalizationConfig,
SelectField,
Tab,
TabsField,
TextareaField,
TextField,
UploadField,
} from 'payload'
import mongoose from 'mongoose'
import {
type ArrayField,
type Block,
type BlocksField,
type CheckboxField,
type CodeField,
type CollapsibleField,
type DateField,
type EmailField,
type Field,
type FieldAffectingData,
type GroupField,
type JSONField,
type NonPresentationalField,
type NumberField,
type Payload,
type PointField,
type RadioField,
type RelationshipField,
type RichTextField,
type RowField,
type SanitizedLocalizationConfig,
type SelectField,
type Tab,
type TabsField,
type TextareaField,
type TextField,
type UploadField,
} from 'payload'
import {
fieldAffectsData,
fieldIsLocalized,
@@ -49,7 +49,7 @@ export type BuildSchemaOptions = {
type FieldSchemaGenerator = (
field: Field,
schema: Schema,
config: SanitizedConfig,
config: Payload,
buildSchemaOptions: BuildSchemaOptions,
) => void
@@ -113,7 +113,7 @@ const localizeSchema = (
}
export const buildSchema = (
config: SanitizedConfig,
payload: Payload,
configFields: Field[],
buildSchemaOptions: BuildSchemaOptions = {},
): Schema => {
@@ -145,7 +145,7 @@ export const buildSchema = (
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[field.type]
if (addFieldSchema) {
addFieldSchema(field, schema, config, buildSchemaOptions)
addFieldSchema(field, schema, payload, buildSchemaOptions)
}
}
})
@@ -157,13 +157,13 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
array: (
field: ArrayField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
) => {
const baseSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: [
buildSchema(config, field.fields, {
buildSchema(payload, field.fields, {
allowIDField: true,
disableUnique: buildSchemaOptions.disableUnique,
draftsEnabled: buildSchemaOptions.draftsEnabled,
@@ -177,13 +177,13 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
blocks: (
field: BlocksField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const fieldSchema = {
@@ -191,7 +191,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, fieldSchema, config.localization),
[field.name]: localizeSchema(field, fieldSchema, payload.config.localization),
})
field.blocks.forEach((blockItem: Block) => {
@@ -200,12 +200,12 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
blockItem.fields.forEach((blockField) => {
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[blockField.type]
if (addFieldSchema) {
addFieldSchema(blockField, blockSchema, config, buildSchemaOptions)
addFieldSchema(blockField, blockSchema, payload, buildSchemaOptions)
}
})
if (field.localized && config.localization) {
config.localization.localeCodes.forEach((localeCode) => {
if (field.localized && payload.config.localization) {
payload.config.localization.localeCodes.forEach((localeCode) => {
// @ts-expect-error Possible incorrect typing in mongoose types, this works
schema.path(`${field.name}.${localeCode}`).discriminator(blockItem.slug, blockSchema)
})
@@ -218,31 +218,31 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
checkbox: (
field: CheckboxField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Boolean }
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
code: (
field: CodeField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
collapsible: (
field: CollapsibleField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
field.fields.forEach((subField: Field) => {
@@ -253,38 +253,38 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]
if (addFieldSchema) {
addFieldSchema(subField, schema, config, buildSchemaOptions)
addFieldSchema(subField, schema, payload, buildSchemaOptions)
}
})
},
date: (
field: DateField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Date }
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
email: (
field: EmailField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
group: (
field: GroupField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const formattedBaseSchema = formatBaseSchema(field, buildSchemaOptions)
@@ -297,7 +297,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
const baseSchema = {
...formattedBaseSchema,
type: buildSchema(config, field.fields, {
type: buildSchema(payload, field.fields, {
disableUnique: buildSchemaOptions.disableUnique,
draftsEnabled: buildSchemaOptions.draftsEnabled,
indexSortableFields,
@@ -310,13 +310,13 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
json: (
field: JSONField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
@@ -325,13 +325,13 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
number: (
field: NumberField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
@@ -340,13 +340,13 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
point: (
field: PointField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema: SchemaTypeOptions<unknown> = {
@@ -368,7 +368,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
if (field.index === true || field.index === undefined) {
@@ -377,8 +377,8 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
indexOptions.sparse = true
indexOptions.unique = true
}
if (field.localized && config.localization) {
config.localization.locales.forEach((locale) => {
if (field.localized && payload.config.localization) {
payload.config.localization.locales.forEach((locale) => {
schema.index({ [`${field.name}.${locale.code}`]: '2dsphere' }, indexOptions)
})
} else {
@@ -389,7 +389,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
radio: (
field: RadioField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
@@ -404,21 +404,23 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
relationship: (
field: RelationshipField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
) => {
const hasManyRelations = Array.isArray(field.relationTo)
let schemaToReturn: { [key: string]: any } = {}
if (field.localized && config.localization) {
const valueType = getRelationshipValueType(field, payload)
if (field.localized && payload.config.localization) {
schemaToReturn = {
type: config.localization.localeCodes.reduce((locales, locale) => {
type: payload.config.localization.localeCodes.reduce((locales, locale) => {
let localeSchema: { [key: string]: any } = {}
if (hasManyRelations) {
@@ -428,14 +430,14 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
type: valueType,
refPath: `${field.name}.${locale}.relationTo`,
},
}
} else {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
type: valueType,
ref: field.relationTo,
}
}
@@ -456,7 +458,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
type: valueType,
refPath: `${field.name}.relationTo`,
},
}
@@ -470,7 +472,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
} else {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
type: valueType,
ref: field.relationTo,
}
@@ -489,7 +491,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
richText: (
field: RichTextField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
@@ -498,13 +500,13 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
row: (
field: RowField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
field.fields.forEach((subField: Field) => {
@@ -515,14 +517,14 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]
if (addFieldSchema) {
addFieldSchema(subField, schema, config, buildSchemaOptions)
addFieldSchema(subField, schema, payload, buildSchemaOptions)
}
})
},
select: (
field: SelectField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
@@ -544,14 +546,14 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
[field.name]: localizeSchema(
field,
field.hasMany ? [baseSchema] : baseSchema,
config.localization,
payload.config.localization,
),
})
},
tabs: (
field: TabsField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
field.tabs.forEach((tab) => {
@@ -560,7 +562,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
return
}
const baseSchema = {
type: buildSchema(config, tab.fields, {
type: buildSchema(payload, tab.fields, {
disableUnique: buildSchemaOptions.disableUnique,
draftsEnabled: buildSchemaOptions.draftsEnabled,
options: {
@@ -572,7 +574,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[tab.name]: localizeSchema(tab, baseSchema, config.localization),
[tab.name]: localizeSchema(tab, baseSchema, payload.config.localization),
})
} else {
tab.fields.forEach((subField: Field) => {
@@ -582,7 +584,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]
if (addFieldSchema) {
addFieldSchema(subField, schema, config, buildSchemaOptions)
addFieldSchema(subField, schema, payload, buildSchemaOptions)
}
})
}
@@ -591,7 +593,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
text: (
field: TextField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
@@ -600,33 +602,35 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
textarea: (
field: TextareaField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: localizeSchema(field, baseSchema, payload.config.localization),
})
},
upload: (
field: UploadField,
schema: Schema,
config: SanitizedConfig,
payload: Payload,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const hasManyRelations = Array.isArray(field.relationTo)
let schemaToReturn: { [key: string]: any } = {}
if (field.localized && config.localization) {
const valueType = getRelationshipValueType(field, payload)
if (field.localized && payload.config.localization) {
schemaToReturn = {
type: config.localization.localeCodes.reduce((locales, locale) => {
type: payload.config.localization.localeCodes.reduce((locales, locale) => {
let localeSchema: { [key: string]: any } = {}
if (hasManyRelations) {
@@ -636,14 +640,14 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
type: valueType,
refPath: `${field.name}.${locale}.relationTo`,
},
}
} else {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
type: valueType,
ref: field.relationTo,
}
}
@@ -664,7 +668,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
type: valueType,
refPath: `${field.name}.relationTo`,
},
}
@@ -678,7 +682,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
} else {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
type: valueType,
ref: field.relationTo,
}
@@ -695,3 +699,30 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
})
},
}
const getRelationshipValueType = (field: RelationshipField | UploadField, payload: Payload) => {
if (typeof field.relationTo === 'string') {
const { customIDType } = payload.collections[field.relationTo]
if (!customIDType) {
return mongoose.Schema.Types.ObjectId
}
if (customIDType === 'number') {
return mongoose.Schema.Types.Number
}
return mongoose.Schema.Types.String
}
// has custom id relationTo
if (
field.relationTo.some((relationTo) => {
return !!payload.collections[relationTo].customIDType
})
) {
return mongoose.Schema.Types.Mixed
}
return mongoose.Schema.Types.ObjectId
}

View File

@@ -0,0 +1,183 @@
import type { ClientSession, Model } from 'mongoose'
import type { Field, PayloadRequest, SanitizedConfig } from 'payload'
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
import type { MongooseAdapter } from '../index.js'
import { sanitizeRelationshipIDs } from '../utilities/sanitizeRelationshipIDs.js'
import { withSession } from '../withSession.js'
const migrateModelWithBatching = async ({
batchSize,
config,
fields,
Model,
session,
}: {
batchSize: number
config: SanitizedConfig
fields: Field[]
Model: Model<any>
session: ClientSession
}): Promise<void> => {
let hasNext = true
let skip = 0
while (hasNext) {
const docs = await Model.find(
{},
{},
{
lean: true,
limit: batchSize + 1,
session,
skip,
},
)
if (docs.length === 0) {
break
}
hasNext = docs.length > batchSize
if (hasNext) {
docs.pop()
}
for (const doc of docs) {
sanitizeRelationshipIDs({ config, data: doc, fields })
}
await Model.collection.bulkWrite(
docs.map((doc) => ({
updateOne: {
filter: { _id: doc._id },
update: {
$set: doc,
},
},
})),
{ session },
)
skip += batchSize
}
}
const hasRelationshipOrUploadField = ({ fields }: { fields: Field[] }): boolean => {
for (const field of fields) {
if (field.type === 'relationship' || field.type === 'upload') {
return true
}
if ('fields' in field) {
if (hasRelationshipOrUploadField({ fields: field.fields })) {
return true
}
}
if ('blocks' in field) {
for (const block of field.blocks) {
if (hasRelationshipOrUploadField({ fields: block.fields })) {
return true
}
}
}
if ('tabs' in field) {
for (const tab of field.tabs) {
if (hasRelationshipOrUploadField({ fields: tab.fields })) {
return true
}
}
}
}
return false
}
export async function migrateRelationshipsV2_V3({
batchSize,
req,
}: {
batchSize: number
req: PayloadRequest
}): Promise<void> {
const { payload } = req
const db = payload.db as MongooseAdapter
const config = payload.config
const { session } = await withSession(db, req)
for (const collection of payload.config.collections.filter(hasRelationshipOrUploadField)) {
payload.logger.info(`Migrating collection "${collection.slug}"`)
await migrateModelWithBatching({
batchSize,
config,
fields: collection.fields,
Model: db.collections[collection.slug],
session,
})
payload.logger.info(`Migrated collection "${collection.slug}"`)
if (collection.versions) {
payload.logger.info(`Migrating collection versions "${collection.slug}"`)
await migrateModelWithBatching({
batchSize,
config,
fields: buildVersionCollectionFields(config, collection),
Model: db.versions[collection.slug],
session,
})
payload.logger.info(`Migrated collection versions "${collection.slug}"`)
}
}
const { globals: GlobalsModel } = db
for (const global of payload.config.globals.filter(hasRelationshipOrUploadField)) {
payload.logger.info(`Migrating global "${global.slug}"`)
const doc = await GlobalsModel.findOne<Record<string, unknown>>(
{
globalType: {
$eq: global.slug,
},
},
{},
{ lean: true, session },
)
sanitizeRelationshipIDs({ config, data: doc, fields: global.fields })
await GlobalsModel.collection.updateOne(
{
globalType: global.slug,
},
{ $set: doc },
{ session },
)
payload.logger.info(`Migrated global "${global.slug}"`)
if (global.versions) {
payload.logger.info(`Migrating global versions "${global.slug}"`)
await migrateModelWithBatching({
batchSize,
config,
fields: buildVersionGlobalFields(config, global),
Model: db.versions[global.slug],
session,
})
payload.logger.info(`Migrated global versions "${global.slug}"`)
}
}
}

View File

@@ -0,0 +1,126 @@
import type { ClientSession } from 'mongoose'
import type { Payload, PayloadRequest } from 'payload'
import type { MongooseAdapter } from '../index.js'
import { withSession } from '../withSession.js'
export async function migrateVersionsV1_V2({ req }: { req: PayloadRequest }) {
const { payload } = req
const { session } = await withSession(payload.db as MongooseAdapter, req)
// For each collection
for (const { slug, versions } of payload.config.collections) {
if (versions?.drafts) {
await migrateCollectionDocs({ slug, payload, session })
payload.logger.info(`Migrated the "${slug}" collection.`)
}
}
// For each global
for (const { slug, versions } of payload.config.globals) {
if (versions) {
const VersionsModel = payload.db.versions[slug]
await VersionsModel.findOneAndUpdate(
{},
{ latest: true },
{
session,
sort: { updatedAt: -1 },
},
).exec()
payload.logger.info(`Migrated the "${slug}" global.`)
}
}
}
async function migrateCollectionDocs({
slug,
docsAtATime = 100,
payload,
session,
}: {
docsAtATime?: number
payload: Payload
session: ClientSession
slug: string
}) {
const VersionsModel = payload.db.versions[slug]
const remainingDocs = await VersionsModel.aggregate(
[
// Sort so that newest are first
{
$sort: {
updatedAt: -1,
},
},
// Group by parent ID
// take the $first of each
{
$group: {
_id: '$parent',
_versionID: { $first: '$_id' },
createdAt: { $first: '$createdAt' },
latest: { $first: '$latest' },
updatedAt: { $first: '$updatedAt' },
version: { $first: '$version' },
},
},
{
$match: {
latest: { $eq: null },
},
},
{
$limit: docsAtATime,
},
],
{
allowDiskUse: true,
session,
},
).exec()
if (!remainingDocs || remainingDocs.length === 0) {
const newVersions = await VersionsModel.find(
{
latest: {
$eq: true,
},
},
undefined,
{ session },
)
if (newVersions?.length) {
payload.logger.info(
`Migrated ${newVersions.length} documents in the "${slug}" versions collection.`,
)
}
return
}
const remainingDocIds = remainingDocs.map((doc) => doc._versionID)
await VersionsModel.updateMany(
{
_id: {
$in: remainingDocIds,
},
},
{
latest: true,
},
{
session,
},
)
await migrateCollectionDocs({ slug, payload, session })
}

View File

@@ -0,0 +1,7 @@
const imports = `import { migrateRelationshipsV2_V3 } from '@payloadcms/db-mongodb/migration-utils'`
const upSQL = ` await migrateRelationshipsV2_V3({
batchSize: 100,
req,
})
`
export { imports, upSQL }

View File

@@ -1,96 +0,0 @@
module.exports.up = ` async function migrateCollectionDocs(slug: string, docsAtATime = 100) {
const VersionsModel = payload.db.versions[slug]
const remainingDocs = await VersionsModel.aggregate(
[
// Sort so that newest are first
{
$sort: {
updatedAt: -1,
},
},
// Group by parent ID
// take the $first of each
{
$group: {
_id: '$parent',
_versionID: { $first: '$_id' },
createdAt: { $first: '$createdAt' },
latest: { $first: '$latest' },
updatedAt: { $first: '$updatedAt' },
version: { $first: '$version' },
},
},
{
$match: {
latest: { $eq: null },
},
},
{
$limit: docsAtATime,
},
],
{
allowDiskUse: true,
},
).exec()
if (!remainingDocs || remainingDocs.length === 0) {
const newVersions = await VersionsModel.find({
latest: {
$eq: true,
},
})
if (newVersions?.length) {
payload.logger.info(
\`Migrated \${newVersions.length} documents in the "\${slug}" versions collection.\`,
)
}
return
}
const remainingDocIds = remainingDocs.map((doc) => doc._versionID)
await VersionsModel.updateMany(
{
_id: {
$in: remainingDocIds,
},
},
{
latest: true,
},
)
await migrateCollectionDocs(slug)
}
// For each collection
await Promise.all(
payload.config.collections.map(async ({ slug, versions }) => {
if (versions?.drafts) {
return migrateCollectionDocs(slug)
}
}),
)
// For each global
await Promise.all(
payload.config.globals.map(async ({ slug, versions }) => {
if (versions) {
const VersionsModel = payload.db.versions[slug]
await VersionsModel.findOneAndUpdate(
{},
{ latest: true },
{
sort: { updatedAt: -1 },
},
).exec()
payload.logger.info(\`Migrated the "\${slug}" global.\`)
}
}),
)
`

View File

@@ -0,0 +1,6 @@
const imports = `import { migrateVersionsV1_V2 } from '@payloadcms/db-mongodb/migration-utils'`
const upSQL = ` await migrateVersionsV1_V2({
req,
})
`
export { imports, upSQL }

View File

@@ -1,7 +1,6 @@
import type { Field, Operator, PathToQuery, Payload } from 'payload'
import ObjectIdImport from 'bson-objectid'
import mongoose from 'mongoose'
import { Types } from 'mongoose'
import { getLocalizedPaths } from 'payload'
import { validOperators } from 'payload/shared'
@@ -10,9 +9,6 @@ import type { MongooseAdapter } from '../index.js'
import { operatorMap } from './operatorMap.js'
import { sanitizeQueryValue } from './sanitizeQueryValue.js'
const ObjectId = (ObjectIdImport.default ||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
type SearchParam = {
path?: string
rawQuery?: unknown
@@ -87,13 +83,13 @@ export async function buildSearchParam({
}
const [{ field, path }] = paths
if (path) {
const sanitizedQueryValue = sanitizeQueryValue({
field,
hasCustomID,
operator,
path,
payload,
val,
})
@@ -145,7 +141,7 @@ export async function buildSearchParam({
const stringID = doc._id.toString()
$in.push(stringID)
if (mongoose.Types.ObjectId.isValid(stringID)) {
if (Types.ObjectId.isValid(stringID)) {
$in.push(doc._id)
}
})
@@ -207,9 +203,9 @@ export async function buildSearchParam({
}
if (typeof formattedValue === 'string') {
if (mongoose.Types.ObjectId.isValid(formattedValue)) {
if (Types.ObjectId.isValid(formattedValue)) {
result.value[multiIDCondition].push({
[path]: { [operatorKey]: ObjectId(formattedValue) },
[path]: { [operatorKey]: new Types.ObjectId(formattedValue) },
})
} else {
;(Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo]).forEach(

View File

@@ -71,7 +71,10 @@ export async function parseParams({
[searchParam.path]: searchParam.value,
}
} else if (typeof searchParam?.value === 'object') {
result = deepMergeWithCombinedArrays(result, searchParam.value)
result = deepMergeWithCombinedArrays(result, searchParam.value, {
// dont clone Types.ObjectIDs
clone: false,
})
}
}
}

View File

@@ -1,25 +1,25 @@
import type { Field, TabAsField } from 'payload'
import type { Block, Field, Payload, RelationshipField, TabAsField } from 'payload'
import ObjectIdImport from 'bson-objectid'
import mongoose from 'mongoose'
import { createArrayFromCommaDelineated } from 'payload'
import { Types } from 'mongoose'
import { createArrayFromCommaDelineated, flattenTopLevelFields } from 'payload'
type SanitizeQueryValueArgs = {
field: Field | TabAsField
hasCustomID: boolean
operator: string
path: string
payload: Payload
val: any
}
const buildExistsQuery = (formattedValue, path) => {
const buildExistsQuery = (formattedValue, path, treatEmptyString = true) => {
if (formattedValue) {
return {
rawQuery: {
$and: [
{ [path]: { $exists: true } },
{ [path]: { $ne: null } },
{ [path]: { $ne: '' } }, // Exclude null and empty string
...(treatEmptyString ? [{ [path]: { $ne: '' } }] : []), // Treat empty string as null / undefined
],
},
}
@@ -29,20 +29,56 @@ const buildExistsQuery = (formattedValue, path) => {
$or: [
{ [path]: { $exists: false } },
{ [path]: { $eq: null } },
{ [path]: { $eq: '' } }, // Treat empty string as null / undefined
...(treatEmptyString ? [{ [path]: { $eq: '' } }] : []), // Treat empty string as null / undefined
],
},
}
}
}
const ObjectId = (ObjectIdImport.default ||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
// returns nestedField Field object from blocks.nestedField path because getLocalizedPaths splits them only for relationships
const getFieldFromSegments = ({
field,
segments,
}: {
field: Block | Field | TabAsField
segments: string[]
}) => {
if ('blocks' in field) {
for (const block of field.blocks) {
const field = getFieldFromSegments({ field: block, segments })
if (field) {
return field
}
}
}
if ('fields' in field) {
for (let i = 0; i < segments.length; i++) {
const foundField = flattenTopLevelFields(field.fields).find(
(each) => each.name === segments[i],
)
if (!foundField) {
break
}
if (foundField && segments.length - 1 === i) {
return foundField
}
segments.shift()
return getFieldFromSegments({ field: foundField, segments })
}
}
}
export const sanitizeQueryValue = ({
field,
hasCustomID,
operator,
path,
payload,
val,
}: SanitizeQueryValueArgs): {
operator?: string
@@ -52,21 +88,31 @@ export const sanitizeQueryValue = ({
let formattedValue = val
let formattedOperator = operator
if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) {
const segments = path.split('.')
segments.shift()
const foundField = getFieldFromSegments({ field, segments })
if (foundField) {
field = foundField
}
}
// Disregard invalid _ids
if (path === '_id') {
if (typeof val === 'string' && val.split(',').length === 1) {
if (!hasCustomID) {
const isValid = mongoose.Types.ObjectId.isValid(val)
const isValid = Types.ObjectId.isValid(val)
if (!isValid) {
return { operator: formattedOperator, val: undefined }
} else {
if (['in', 'not_in'].includes(operator)) {
formattedValue = createArrayFromCommaDelineated(formattedValue).map((id) =>
ObjectId(id),
formattedValue = createArrayFromCommaDelineated(formattedValue).map(
(id) => new Types.ObjectId(id),
)
} else {
formattedValue = ObjectId(val)
formattedValue = new Types.ObjectId(val)
}
}
}
@@ -84,21 +130,22 @@ export const sanitizeQueryValue = ({
}
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal]
if (!hasCustomID) {
if (mongoose.Types.ObjectId.isValid(inVal)) {
newValues.push(ObjectId(inVal))
if (Types.ObjectId.isValid(inVal)) {
formattedValues.push(new Types.ObjectId(inVal))
}
}
if (field.type === 'number') {
const parsedNumber = parseFloat(inVal)
if (!Number.isNaN(parsedNumber)) {
newValues.push(parsedNumber)
formattedValues.push(parsedNumber)
}
} else {
formattedValues.push(inVal)
}
return [...formattedValues, ...newValues]
return formattedValues
}, [])
}
}
@@ -154,10 +201,10 @@ export const sanitizeQueryValue = ({
formattedValue.relationTo
) {
const { value } = formattedValue
const isValid = mongoose.Types.ObjectId.isValid(value)
const isValid = Types.ObjectId.isValid(value)
if (isValid) {
formattedValue.value = ObjectId(value)
formattedValue.value = new Types.ObjectId(value)
}
return {
@@ -170,25 +217,88 @@ export const sanitizeQueryValue = ({
}
}
const relationTo = (field as RelationshipField).relationTo
if (['in', 'not_in'].includes(operator) && Array.isArray(formattedValue)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal]
if (mongoose.Types.ObjectId.isValid(inVal)) {
newValues.push(ObjectId(inVal))
if (!inVal) {
return formattedValues
}
const parsedNumber = parseFloat(inVal)
if (!Number.isNaN(parsedNumber)) {
newValues.push(parsedNumber)
if (typeof relationTo === 'string' && payload.collections[relationTo].customIDType) {
if (payload.collections[relationTo].customIDType === 'number') {
const parsedNumber = parseFloat(inVal)
if (!Number.isNaN(parsedNumber)) {
formattedValues.push(parsedNumber)
return formattedValues
}
}
formattedValues.push(inVal)
return formattedValues
}
return [...formattedValues, ...newValues]
if (
Array.isArray(relationTo) &&
relationTo.some((relationTo) => !!payload.collections[relationTo].customIDType)
) {
if (Types.ObjectId.isValid(inVal.toString())) {
formattedValues.push(new Types.ObjectId(inVal))
} else {
formattedValues.push(inVal)
}
return formattedValues
}
if (Types.ObjectId.isValid(inVal.toString())) {
formattedValues.push(new Types.ObjectId(inVal))
}
return formattedValues
}, [])
}
if (operator === 'contains' && typeof formattedValue === 'string') {
if (mongoose.Types.ObjectId.isValid(formattedValue)) {
formattedValue = ObjectId(formattedValue)
if (
['contains', 'equals', 'like', 'not_equals'].includes(operator) &&
(!Array.isArray(relationTo) || !path.endsWith('.relationTo'))
) {
if (typeof relationTo === 'string') {
const customIDType = payload.collections[relationTo].customIDType
if (customIDType) {
if (customIDType === 'number') {
formattedValue = parseFloat(val)
if (Number.isNaN(formattedValue)) {
return { operator: formattedOperator, val: undefined }
}
}
} else {
if (!Types.ObjectId.isValid(formattedValue)) {
return { operator: formattedOperator, val: undefined }
}
formattedValue = new Types.ObjectId(formattedValue)
}
} else {
const hasCustomIDType = relationTo.some(
(relationTo) => !!payload.collections[relationTo].customIDType,
)
if (hasCustomIDType) {
if (typeof val === 'string') {
const formattedNumber = Number(val)
formattedValue = [Types.ObjectId.isValid(val) ? new Types.ObjectId(val) : val]
formattedOperator = operator === 'not_equals' ? 'not_in' : 'in'
if (!Number.isNaN(formattedNumber)) {
formattedValue.push(formattedNumber)
}
}
} else {
if (!Types.ObjectId.isValid(formattedValue)) {
return { operator: formattedOperator, val: undefined }
}
formattedValue = new Types.ObjectId(formattedValue)
}
}
}
}
@@ -232,7 +342,7 @@ export const sanitizeQueryValue = ({
}
if (path !== '_id' || (path === '_id' && hasCustomID && field.type === 'text')) {
if (operator === 'contains' && !mongoose.Types.ObjectId.isValid(formattedValue)) {
if (operator === 'contains' && !Types.ObjectId.isValid(formattedValue)) {
formattedValue = {
$options: 'i',
$regex: formattedValue.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'),
@@ -242,7 +352,12 @@ export const sanitizeQueryValue = ({
if (operator === 'exists') {
formattedValue = formattedValue === 'true' || formattedValue === true
return buildExistsQuery(formattedValue, path)
// _id can't be empty string, will error Cast to ObjectId failed for value ""
return buildExistsQuery(
formattedValue,
path,
!['relationship', 'upload'].includes(field.type),
)
}
}

View File

@@ -65,7 +65,6 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
const useEstimatedCount =
hasNearConstraint || !versionQuery || Object.keys(versionQuery).length === 0
const paginationOptions: PaginateOptions = {
forceCountFn: hasNearConstraint,
lean: true,
leanWithId: true,
options,

View File

@@ -1,3 +1,4 @@
import type { QueryOptions } from 'mongoose'
import type { PayloadRequest, UpdateGlobal } from 'payload'
import type { MongooseAdapter } from './index.js'
@@ -9,12 +10,13 @@ import { withSession } from './withSession.js'
export const updateGlobal: UpdateGlobal = async function updateGlobal(
this: MongooseAdapter,
{ slug, data, req = {} as PayloadRequest, select },
{ slug, data, options: optionsArgs = {}, req = {} as PayloadRequest, select },
) {
const Model = this.globals
const fields = this.payload.config.globals.find((global) => global.slug === slug).fields
const options = {
const options: QueryOptions = {
...optionsArgs,
...(await withSession(this, req)),
lean: true,
new: true,

View File

@@ -1,3 +1,5 @@
import type { QueryOptions } from 'mongoose'
import {
buildVersionGlobalFields,
type PayloadRequest,
@@ -17,6 +19,7 @@ export async function updateGlobalVersion<T extends TypeWithID>(
id,
global: globalSlug,
locale,
options: optionsArgs = {},
req = {} as PayloadRequest,
select,
versionData,
@@ -30,7 +33,8 @@ export async function updateGlobalVersion<T extends TypeWithID>(
this.payload.config.globals.find((global) => global.slug === globalSlug),
)
const options = {
const options: QueryOptions = {
...optionsArgs,
...(await withSession(this, req)),
lean: true,
new: true,

View File

@@ -1,3 +1,5 @@
import type { QueryOptions } from 'mongoose'
import { buildVersionCollectionFields, type PayloadRequest, type UpdateVersion } from 'payload'
import type { MongooseAdapter } from './index.js'
@@ -8,7 +10,16 @@ import { withSession } from './withSession.js'
export const updateVersion: UpdateVersion = async function updateVersion(
this: MongooseAdapter,
{ id, collection, locale, req = {} as PayloadRequest, select, versionData, where },
{
id,
collection,
locale,
options: optionsArgs = {},
req = {} as PayloadRequest,
select,
versionData,
where,
},
) {
const VersionModel = this.versions[collection]
const whereToUse = where || { id: { equals: id } }
@@ -17,7 +28,8 @@ export const updateVersion: UpdateVersion = async function updateVersion(
this.payload.collections[collection].config,
)
const options = {
const options: QueryOptions = {
...optionsArgs,
...(await withSession(this, req)),
lean: true,
new: true,

View File

@@ -106,7 +106,6 @@ const traverseFields = ({
switch (field.type) {
case 'array':
case 'group':
case 'tab': {
let fieldSelect: SelectType

View File

@@ -0,0 +1,344 @@
import type { Field, SanitizedConfig } from 'payload'
import { Types } from 'mongoose'
import { sanitizeRelationshipIDs } from './sanitizeRelationshipIDs.js'
const flattenRelationshipValues = (obj: Record<string, any>, prefix = ''): Record<string, any> => {
return Object.keys(obj).reduce(
(acc, key) => {
const fullKey = prefix ? `${prefix}.${key}` : key
const value = obj[key]
if (value && typeof value === 'object' && !(value instanceof Types.ObjectId)) {
Object.assign(acc, flattenRelationshipValues(value, fullKey))
// skip relationTo and blockType
} else if (!fullKey.endsWith('relationTo') && !fullKey.endsWith('blockType')) {
acc[fullKey] = value
}
return acc
},
{} as Record<string, any>,
)
}
const relsFields: Field[] = [
{
name: 'rel_1',
type: 'relationship',
relationTo: 'rels',
},
{
name: 'rel_1_l',
type: 'relationship',
localized: true,
relationTo: 'rels',
},
{
name: 'rel_2',
type: 'relationship',
hasMany: true,
relationTo: 'rels',
},
{
name: 'rel_2_l',
type: 'relationship',
hasMany: true,
localized: true,
relationTo: 'rels',
},
{
name: 'rel_3',
type: 'relationship',
relationTo: ['rels'],
},
{
name: 'rel_3_l',
type: 'relationship',
localized: true,
relationTo: ['rels'],
},
{
name: 'rel_4',
type: 'relationship',
hasMany: true,
relationTo: ['rels'],
},
{
name: 'rel_4_l',
type: 'relationship',
hasMany: true,
localized: true,
relationTo: ['rels'],
},
]
const config = {
collections: [
{
slug: 'docs',
fields: [
...relsFields,
{
name: 'array',
type: 'array',
fields: [
{
name: 'array',
type: 'array',
fields: relsFields,
},
{
name: 'blocks',
type: 'blocks',
blocks: [{ slug: 'block', fields: relsFields }],
},
...relsFields,
],
},
{
name: 'arrayLocalized',
type: 'array',
fields: [
{
name: 'array',
type: 'array',
fields: relsFields,
},
{
name: 'blocks',
type: 'blocks',
blocks: [{ slug: 'block', fields: relsFields }],
},
...relsFields,
],
localized: true,
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block',
fields: [
...relsFields,
{
name: 'group',
type: 'group',
fields: relsFields,
},
{
name: 'array',
type: 'array',
fields: relsFields,
},
],
},
],
},
{
name: 'group',
type: 'group',
fields: [
...relsFields,
{
name: 'array',
type: 'array',
fields: relsFields,
},
],
},
{
name: 'groupLocalized',
type: 'group',
fields: [
...relsFields,
{
name: 'array',
type: 'array',
fields: relsFields,
},
],
localized: true,
},
{
name: 'groupAndRow',
type: 'group',
fields: [
{
type: 'row',
fields: [
...relsFields,
{
type: 'array',
name: 'array',
fields: relsFields,
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'tab',
fields: relsFields,
},
{
name: 'tabLocalized',
fields: relsFields,
localized: true,
},
],
},
],
},
{
slug: 'rels',
fields: [],
},
],
localization: {
defaultLocale: 'en',
localeCodes: ['en', 'es'],
locales: [
{ code: 'en', label: 'EN' },
{ code: 'es', label: 'ES' },
],
},
} as SanitizedConfig
const relsData = {
rel_1: new Types.ObjectId().toHexString(),
rel_1_l: {
en: new Types.ObjectId().toHexString(),
es: new Types.ObjectId().toHexString(),
},
rel_2: [new Types.ObjectId().toHexString()],
rel_2_l: {
en: [new Types.ObjectId().toHexString()],
es: [new Types.ObjectId().toHexString()],
},
rel_3: {
relationTo: 'rels',
value: new Types.ObjectId().toHexString(),
},
rel_3_l: {
en: {
relationTo: 'rels',
value: new Types.ObjectId().toHexString(),
},
es: {
relationTo: 'rels',
value: new Types.ObjectId().toHexString(),
},
},
rel_4: [
{
relationTo: 'rels',
value: new Types.ObjectId().toHexString(),
},
],
rel_4_l: {
en: [
{
relationTo: 'rels',
value: new Types.ObjectId().toHexString(),
},
],
es: [
{
relationTo: 'rels',
value: new Types.ObjectId().toHexString(),
},
],
},
}
describe('sanitizeRelationshipIDs', () => {
it('should sanitize relationships', () => {
const data = {
...relsData,
array: [
{
...relsData,
array: [{ ...relsData }],
blocks: [
{
blockType: 'block',
...relsData,
},
],
},
],
arrayLocalized: {
en: [
{
...relsData,
array: [{ ...relsData }],
blocks: [
{
blockType: 'block',
...relsData,
},
],
},
],
es: [
{
...relsData,
array: [{ ...relsData }],
blocks: [
{
blockType: 'block',
...relsData,
},
],
},
],
},
blocks: [
{
blockType: 'block',
...relsData,
array: [{ ...relsData }],
group: { ...relsData },
},
],
group: {
...relsData,
array: [{ ...relsData }],
},
groupAndRow: {
...relsData,
array: [{ ...relsData }],
},
groupLocalized: {
en: {
...relsData,
array: [{ ...relsData }],
},
es: {
...relsData,
array: [{ ...relsData }],
},
},
tab: { ...relsData },
tabLocalized: {
en: { ...relsData },
es: { ...relsData },
},
}
const flattenValuesBefore = Object.values(flattenRelationshipValues(data))
sanitizeRelationshipIDs({ config, data, fields: config.collections[0].fields })
const flattenValuesAfter = Object.values(flattenRelationshipValues(data))
flattenValuesAfter.forEach((value, i) => {
expect(value).toBeInstanceOf(Types.ObjectId)
expect(flattenValuesBefore[i]).toBe(value.toHexString())
})
})
})

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload'
import mongoose from 'mongoose'
import { Types } from 'mongoose'
import { APIError, traverseFields } from 'payload'
import { fieldAffectsData } from 'payload/shared'
@@ -25,14 +25,14 @@ const convertValue = ({
}: {
relatedCollection: CollectionConfig
value: number | string
}): mongoose.Types.ObjectId | number | string => {
}): number | string | Types.ObjectId => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (!customIDField) {
try {
return new mongoose.Types.ObjectId(value)
return new Types.ObjectId(value)
} catch (error) {
throw new APIError(
`Failed to create ObjectId from value: ${value}. Error: ${error.message}`,
@@ -141,7 +141,7 @@ export const sanitizeRelationshipIDs = ({
}
}
traverseFields({ callback: sanitize, fields, ref: data })
traverseFields({ callback: sanitize, fields, fillEmpty: false, ref: data })
return data
}

View File

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

View File

@@ -50,12 +50,17 @@ import {
requireDrizzleKit,
} from '@payloadcms/drizzle/postgres'
import { pgEnum, pgSchema, pgTable } from 'drizzle-orm/pg-core'
import path from 'path'
import { createDatabaseAdapter, defaultBeginTransaction } from 'payload'
import { fileURLToPath } from 'url'
import type { Args, PostgresAdapter } from './types.js'
import { connect } from './connect.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter> {
const postgresIDType = args.idType || 'serial'
const payloadIDType = postgresIDType === 'serial' ? 'number' : 'text'
@@ -88,6 +93,9 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
beforeSchemaInit: args.beforeSchemaInit ?? [],
createDatabase,
createExtensions,
createMigration(args) {
return createMigration.bind(this)({ ...args, dirname })
},
defaultDrizzleSnapshot,
disableCreateDatabase: args.disableCreateDatabase ?? false,
drizzle: undefined,
@@ -132,7 +140,6 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
createGlobal,
createGlobalVersion,
createJSONQuery,
createMigration,
createVersion,
defaultIDType: payloadIDType,
deleteMany,

View File

@@ -58,8 +58,8 @@ export const traverseFields = (args: Args) => {
})
})
}
case 'collapsible':
case 'collapsible':
case 'row': {
return traverseFields({
...args,
@@ -84,7 +84,6 @@ export const traverseFields = (args: Args) => {
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {

View File

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

View File

@@ -1,5 +1,4 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { Connect } from 'payload'
import { createClient } from '@libsql/client'

View File

@@ -8,7 +8,7 @@ import type {
SQLiteTableWithColumns,
UniqueConstraintBuilder,
} from 'drizzle-orm/sqlite-core'
import type { Field, SanitizedJoins } from 'payload'
import type { Field } from 'payload'
import { buildIndexName, createTableName } from '@payloadcms/drizzle'
import { relations, sql } from 'drizzle-orm'

View File

@@ -1,7 +1,7 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Relation } from 'drizzle-orm'
import type { IndexBuilder, SQLiteColumnBuilder } from 'drizzle-orm/sqlite-core'
import type { Field, SanitizedJoins, TabAsField } from 'payload'
import type { Field, TabAsField } from 'payload'
import {
buildIndexName,
@@ -472,16 +472,15 @@ export const traverseFields = ({
targetTable[fieldName] = withDefault(integer(columnName, { mode: 'boolean' }), field)
break
}
case 'code':
case 'email':
case 'textarea': {
targetTable[fieldName] = withDefault(text(columnName), field)
break
}
case 'collapsible':
case 'collapsible':
case 'row': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
@@ -654,7 +653,6 @@ export const traverseFields = ({
}
case 'json':
case 'richText': {
targetTable[fieldName] = withDefault(text(columnName, { mode: 'json' }), field)
break
@@ -691,8 +689,8 @@ export const traverseFields = ({
case 'point': {
break
}
case 'radio':
case 'radio':
case 'select': {
const options = field.options.map((option) => {
if (optionIsObject(option)) {

View File

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

View File

@@ -50,12 +50,17 @@ import {
requireDrizzleKit,
} from '@payloadcms/drizzle/postgres'
import { pgEnum, pgSchema, pgTable } from 'drizzle-orm/pg-core'
import path from 'path'
import { createDatabaseAdapter, defaultBeginTransaction } from 'payload'
import { fileURLToPath } from 'url'
import type { Args, VercelPostgresAdapter } from './types.js'
import { connect } from './connect.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<VercelPostgresAdapter> {
const postgresIDType = args.idType || 'serial'
const payloadIDType = postgresIDType === 'serial' ? 'number' : 'text'
@@ -133,7 +138,9 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
createGlobal,
createGlobalVersion,
createJSONQuery,
createMigration,
createMigration(args) {
return createMigration.bind(this)({ ...args, dirname })
},
createVersion,
defaultIDType: payloadIDType,
deleteMany,

View File

@@ -58,8 +58,8 @@ export const traverseFields = (args: Args) => {
})
})
}
case 'collapsible':
case 'collapsible':
case 'row': {
return traverseFields({
...args,
@@ -84,7 +84,6 @@ export const traverseFields = (args: Args) => {
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {

View File

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

View File

@@ -2,10 +2,8 @@ import type { CreateMigration } from 'payload'
import fs from 'fs'
import { createRequire } from 'module'
import path from 'path'
import { getPredefinedMigration, writeMigrationIndex } from 'payload'
import prompts from 'prompts'
import { fileURLToPath } from 'url'
import type { BasePostgresAdapter } from './types.js'
@@ -16,10 +14,8 @@ const require = createRequire(import.meta.url)
export const createMigration: CreateMigration = async function createMigration(
this: BasePostgresAdapter,
{ file, forceAcceptWarning, migrationName, payload },
{ dirname, file, forceAcceptWarning, migrationName, payload },
) {
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const dir = payload.db.migrationDir
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.129",
"version": "3.0.0-beta.131",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.129",
"version": "3.0.0-beta.131",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,9 +1,9 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
Permissions,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
@@ -23,7 +23,7 @@ export const DocumentTabs: React.FC<{
globalConfig: SanitizedGlobalConfig
i18n: I18n
payload: Payload
permissions: Permissions
permissions: SanitizedPermissions
}> = (props) => {
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload

View File

@@ -72,9 +72,8 @@ export const tabs: Record<
condition: ({ collectionConfig, globalConfig, permissions }) =>
Boolean(
(collectionConfig?.versions &&
permissions?.collections?.[collectionConfig?.slug]?.readVersions?.permission) ||
(globalConfig?.versions &&
permissions?.globals?.[globalConfig?.slug]?.readVersions?.permission),
permissions?.collections?.[collectionConfig?.slug]?.readVersions) ||
(globalConfig?.versions && permissions?.globals?.[globalConfig?.slug]?.readVersions),
),
href: '/versions',
label: ({ t }) => t('version:versions'),

View File

@@ -1,9 +1,9 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
Permissions,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
} from 'payload'
import { Gutter, RenderTitle } from '@payloadcms/ui'
@@ -20,7 +20,7 @@ export const DocumentHeader: React.FC<{
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: Permissions
permissions: SanitizedPermissions
}> = (props) => {
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props

View File

@@ -1,5 +1,5 @@
import type { I18n, I18nClient } from '@payloadcms/translations'
import type { PayloadRequest, Permissions, SanitizedConfig, User } from 'payload'
import type { PayloadRequest, SanitizedConfig, SanitizedPermissions, User } from 'payload'
import { initI18n } from '@payloadcms/translations'
import { headers as getHeaders } from 'next/headers.js'
@@ -11,7 +11,7 @@ import { getRequestLanguage } from './getRequestLanguage.js'
type Result = {
i18n: I18nClient
permissions: Permissions
permissions: SanitizedPermissions
req: PayloadRequest
user: User
}

View File

@@ -19,6 +19,7 @@ export const LocaleSelector: React.FC<{
options: localeOptions,
}}
onChange={(value: string) => onChange(value)}
path="locale"
/>
)
}

View File

@@ -159,6 +159,7 @@ export const APIViewClient: React.FC = () => {
label: t('version:draft'),
}}
onChange={() => setDraft(!draft)}
path="draft"
/>
)}
<CheckboxField
@@ -167,6 +168,7 @@ export const APIViewClient: React.FC = () => {
label: t('authentication:authenticated'),
}}
onChange={() => setAuthenticated(!authenticated)}
path="authenticated"
/>
</div>
{localeOptions && <LocaleSelector localeOptions={localeOptions} onChange={setLocale} />}
@@ -181,6 +183,7 @@ export const APIViewClient: React.FC = () => {
min: 0,
}}
onChange={(value) => setDepth(value?.toString())}
path="depth"
/>
</div>
</Form>

View File

@@ -36,6 +36,7 @@ export const ToggleTheme: React.FC = () => {
],
}}
onChange={onChange}
path="theme"
value={autoMode ? 'auto' : theme}
/>
)

View File

@@ -2,10 +2,10 @@
import type { FormProps, UserWithToken } from '@payloadcms/ui'
import type {
ClientCollectionConfig,
DocumentPermissions,
DocumentPreferences,
FormState,
LoginWithUsernameOptions,
SanitizedDocumentPermissions,
} from 'payload'
import {
@@ -24,7 +24,7 @@ import { abortAndIgnore } from '@payloadcms/ui/shared'
import React, { useEffect } from 'react'
export const CreateFirstUserClient: React.FC<{
docPermissions: DocumentPermissions
docPermissions: SanitizedDocumentPermissions
docPreferences: DocumentPreferences
initialState: FormState
loginWithUsername?: false | LoginWithUsernameOptions
@@ -105,6 +105,7 @@ export const CreateFirstUserClient: React.FC<{
label: t('authentication:newPassword'),
required: true,
}}
path="password"
/>
<ConfirmPasswordField />
<RenderFields
@@ -113,7 +114,7 @@ export const CreateFirstUserClient: React.FC<{
parentIndexPath=""
parentPath=""
parentSchemaPath={userSlug}
permissions={null}
permissions={true}
readOnly={false}
/>
<FormSubmit size="large">{t('general:create')}</FormSubmit>

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