Compare commits

..

57 Commits

Author SHA1 Message Date
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
Paul
cd95daf029 fix: add inline <head><style> to ensure the order of declared css layers as much as possible (#9123)
Should help alleviate some problems outlined in
https://github.com/payloadcms/payload/issues/8878
2024-11-13 13:49:38 -05:00
James Mikrut
9da85430a5 feat: adds ability to define base filter for list view (#9177)
Adds the ability to define base list view filters, which is super
helpful when you're doing multi-tenant things in Payload.
2024-11-13 18:34:01 +00:00
Paul
f4d526d6e5 fix: fallbackLocale not respecting default settings, locale specific fallbacks and not respecting 'none' or false (#8591)
This PR fixes and improves a few things around localisation and
fallbackLocale:
- For the REST API `fallbackLocale` and `fallback-locale` are treated
the same for consistency with the Local API
- `fallback: false` in config is now respected, by default results will
not fallback to `defaultLocale` unless this config is true, can also be
overridden by providing an explicit `fallbackLocale` in the request
- locale specific fallbacks will now take priority over `defaultLocale`
unless an explicit fallback is provided
- Fixes types on operations to allow `'none'` as a value for
fallbackLocale
- `fallback` is now true by default if unspecified

Closes https://github.com/payloadcms/payload/issues/8443
2024-11-13 12:13:31 -06:00
Patrik
3b55458c0d fix(next): safely check for state when creating first user (#9168)
On createFirstUser, state from form-state was returning null.

![Screenshot 2024-11-13 at 9 58
04 AM](https://github.com/user-attachments/assets/19019e3e-09fc-42e6-9b9a-9198772d9133)

Only return `state` if response from form-state is not null.
2024-11-13 11:19:43 -05:00
Patrik
51dc3f06b1 chore(templates): update lock file for website template (#9169) 2024-11-13 16:15:18 +00:00
Dan Ribbens
d6282221db feat: customize log levels and downgrade common errors to info (#9156)
### What?

Allows configuration of the log level based on the error being thrown
and also downgrades common errors to be info instead of error by
default.

### Why?

Currently all errors result in logger.error being called which can
polute the logs with junk that is normal and doesn't need attention.

### How?

Adds a config property called `loggingLevels` that is used to override
the default log levels based on the name of the error being thrown.
Sanitize config will provide the defaulted 'info' level errors which can
be overriden in the config.

Before
![Screenshot 2024-11-12
144459](https://github.com/user-attachments/assets/47318329-23b7-4627-afc4-a0bcf4dc3d58)

After

![image](https://github.com/user-attachments/assets/85b06be4-0ab8-4ca2-b237-d6a4d54add3a)
2024-11-13 09:24:53 -05:00
Elliot DeNolf
f264c8087a chore: add download/week to README 2024-11-12 21:51:11 -05:00
Elliot DeNolf
1b16730b20 chore: remove useless script, can use HUSKY=0 2024-11-12 20:50:52 -05:00
Jacob Fletcher
f6bdc0aaf6 feat(next): initializes nav group prefs on the server and consolidates records (#9145) 2024-11-12 20:05:12 -05:00
Jarrod Flesch
a8e3095e45 fix: expose server and client props to custom list slot components (#9159)
### What?
Adds `serverProps` and `clientProps` to custom list view slot
components.

### Why?
They were missing and should be exposed.

### How?
Created custom types for list slot components and threads them through
into `renderListSlots` function and passes them through to each
`RenderServerComponent` that renders list view slot components.
2024-11-12 18:18:19 -05:00
Paul
5ac4e73991 feat(templates): update config structure in website template to be more clear (#9161) 2024-11-12 22:56:32 +00:00
Jacob Fletcher
9ee6425761 docs: updates custom components and field props (#9157) 2024-11-12 22:35:29 +00:00
Elliot DeNolf
8c2fc71149 chore(release): v3.0.0-beta.129 [skip ci] 2024-11-12 17:07:25 -05:00
Patrik
88bef2e140 chore: updates flaky uploads tests (#9149)
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2024-11-12 16:41:08 -05:00
Alessio Gravili
a1c99c8b45 fix(richtext-lexical): inline blocks drawer not rendering any fields due to incorrect schemapath suffix (#9158) 2024-11-12 16:31:20 -05:00
Elliot DeNolf
d3cd9baa9b chore(release): v3.0.0-beta.128 [skip ci] 2024-11-12 15:52:52 -05:00
Sasha
64967e4ca6 fix(next): disable turbopack serverExternalPackages warnings (#9147)
### What?
Disables these annoying warnings when running `pnpm dev --turbo`
<img width="656" alt="image"
src="https://github.com/user-attachments/assets/7d0a2990-b5fd-4f5d-a025-665e8e3a7880">
https://github.com/vercel/next.js/issues/68805

### How?
Patches `console.warn` in `withPayload.js`

Can be disabled with `PAYLOAD_PATCH_TURBOPACK_WARNINGS=false` env
variable
2024-11-12 20:20:11 +00:00
James Mikrut
e0309a1dd0 fix: allow specifying queue (#9151)
Allows user to specify a queue when calling `payload.jobs.queue()`.
Closes #9133
2024-11-12 15:00:32 -05:00
James Mikrut
6bb4067bb3 feat: adds option to mongoose to ensure indexes (#9155)
Adds option `ensureIndexes` to Mongoose adapter, which will ensure
indexes are ready prior to completing connection.
2024-11-12 14:42:25 -05:00
Jarrod Flesch
a3ebf51d6e fix: incorrectly looking for schema paths when upload is not enabled (#9146)
### What?
![CleanShot 2024-11-12 at 12 17
56](https://github.com/user-attachments/assets/74b906a3-7e76-4ee9-8b18-bd24dd7fca82)

### Why?
Should not be attaching fields that it does not need.

### How?
Conditionally render slate upload drawer like we do with the toggler.
2024-11-12 14:02:08 -05:00
Elliot DeNolf
280448dd02 chore: remove e-commerce template from readme 2024-11-12 13:36:12 -05:00
Tobias Odendahl
09c41d5c86 fix(db-mongodb)!: use dbName for mongodb model (#9107)
### What?
Uses the `collection.dbName` property for the Mongoose model, if
defined.

### Why?
Currently, `collection.dbName` is used for the version name but not for
the actual collection name. Additionally, `autoPluralization` modifies
the `dbName` regardless. This behavior is inconsistent and contradicts
the documentation.

### How?
- Utilize `collection.dbName` instead of `collection.slug`.
- Disable `autoPluralization` for collections with a defined `dbName`.

Related: https://github.com/payloadcms/payload/discussions/9058

**BREAKING CHANGES**
If a `dbName` was previously provided, it will now be used as the
MongoDB collection name instead of the collection `slug`.
`autoPluralization` will not be applied to `dbName`.

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2024-11-12 13:31:23 -05:00
Elliot DeNolf
def595e645 feat(templates): add with-vercel-website (#9144)
Add new `with-vercel-website` that uses the website template as a base.
2024-11-12 13:12:37 -05:00
James Mikrut
8dd7e989ef Chore/next 15 docs (#9148)
Closes #8995
2024-11-12 17:59:52 +00:00
Nate
7619592fb6 chore: update README asset image (#9143) 2024-11-12 12:51:25 -05:00
James Mikrut
432741bca3 chore: docs improvements (#9142)
Improvements to `select` docs and `jobs-queue` docs
2024-11-12 17:00:21 +00:00
Jarrod Flesch
97cffa51f8 chore: improves abort controller logic for server functions (#9131)
### What?
Removes abort controllers that were shared globally inside the server
actions provider.

### Why?
Constructing them in this way will cause different fetches using the
same function to cancel one another accidentally.

These are currently causing issues when two components call server
functions, even different functions, because the global ref inside was
being overwritten and aborting the previous one.

### How?
Standardizes how we construct and destroy abort controllers. This PR is focused around creating them to pass into the exposed serverAction provider functions. There are other places where this pattern can be applied.
2024-11-12 11:20:17 -05:00
Elliot DeNolf
7cd805adb9 fix(cpa): use proper branch tag (#9141)
The pinned git tag was not being threaded all the way through to where
the download was occurring.
2024-11-12 16:13:19 +00:00
Patrik
48d0faecae fix(next, ui): respect access of user for document locking (#9139) 2024-11-12 15:49:58 +00:00
Alessio Gravili
4f6651433c chore: ensure all packages have consistent licenses and package.json metadata (#9079) 2024-11-12 10:27:36 -05:00
Elliot DeNolf
8a67098f6c chore: add lint commit to .git-blame-ignore-revs 2024-11-12 10:20:42 -05:00
Alessio Gravili
03291472d6 chore: bump all eslint dependencies, run lint and prettier (#9128)
This fixes a peer dependency error in our monorepo, as
eslint-plugin-jsx-a11y finally supports eslint v9.

Additionally, this officially adds TypeScript 5.6 support for
typescript-eslint.
2024-11-12 10:18:22 -05:00
Sasha
3298113a93 fix(ui): pass correct relationTo to locked documents creation (#9137)
### What?
Relationships within `payload-locked-documents` collection were stored
incorrectly with `relationTo` as an array:
<img width="316" alt="image"
src="https://github.com/user-attachments/assets/f84b9807-6032-4ea2-8563-5c7d13306a4a">

Example how it should be:
<img width="405" alt="image"
src="https://github.com/user-attachments/assets/80fb54fd-7c2e-4c90-bd2b-dec8e7a06040">


This additionally caused issue that `value` wasn't converted to
`ObjectID`
2024-11-12 16:14:28 +02:00
Sasha
b878daf27a feat(db-postgres): deep querying on json and rich text fields (#9102)
### What?
Allows to query on JSON / Rich Text fields in Postgres the same way as
in Mongodb with any nesting level.

Example:
Data:
```js
{
  json: {
    array: [
      {
        text: 'some-text', // nested to array + object
        object: {
          text: 'deep-text', // nested to array + 2x object
          array: [10], // number is nested to array + 2x object + array
        },
      },
    ],
  }
}
```
Query:
```ts
payload.find({
  collection: 'json-fields',
  where: {
    and: [
      {
        'json.array.text': {
          equals: 'some-text',
        },
      },
      {
        'json.array.object.text': {
          equals: 'deep-text',
        },
      },
      {
        'json.array.object.array': {
          in: [10, 20],
        },
      },
      {
        'json.array.object.array': {
          exists: true,
        },
      },
      {
        'json.array.object.notexists': {
          exists: false,
        },
      },
    ],
  },
})
```

### How?
Utilizes [the `jsonb_path_exists` postgres
function](https://www.postgresql.org/docs/current/functions-json.html)
2024-11-12 09:26:04 +02:00
Germán Jabloñski
23907e432e feat(richtext-lexical): add useAsTitle to the popup links label (#8718)
Now we show not only the collection being linked to, but also the
document title:

![image](https://github.com/user-attachments/assets/5ba5713a-b051-4f11-ae2a-d5b50a25966b)

Previously this example was just displayed as: `Linked to Users`

- I've added a loading state in case the request is slow (verified with
fake slow connection).
- I have verified that if the `useAsTitle` is not defined, it correctly
fallbacks to the id

Please let me know if the same needs to be done with Slate.

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
2024-11-12 05:07:50 +00:00
Alessio Gravili
a30eeaf644 feat(richtext-lexical): backport relevant from lexical playground between 0.18.0 and 0.20.0 (#9129) 2024-11-12 04:58:28 +00:00
Jacob Fletcher
df764dbbef docs: improves component paths and import map (#9118) 2024-11-11 22:42:48 -05:00
Alessio Gravili
6899a3cc27 fix(db-mongodb): destructuring error when trying to filter date fields by string query (#9116)
Previously, when filtering the internal link relationship in lexical by
typing in the relationship field, it would throw an error, as that
relationship field has a relation to "date-fields".
2024-11-11 20:41:02 -07:00
Alessio Gravili
7261faac57 perf: upgrade pino-pretty. This reduces bundle size and total amount of dependencies from 94 => 85 (#9127)
Previous:

![CleanShot 2024-11-11 at 19 48
05@2x](https://github.com/user-attachments/assets/e1ab5602-92f0-4221-9e7c-98bbba17db71)

94 Dependencies

Now:

![CleanShot 2024-11-11 at 19 48
43@2x](https://github.com/user-attachments/assets/ce5b7ecb-5128-4173-9109-9ddf3493301b)

85 Dependencies
2024-11-11 20:40:20 -07:00
Germán Jabloñski
7767c94bd8 feat(richtext-lexical)!: upgrade lexical from 0.18.0 to 0.20.0 (#9126)
I'm needing https://github.com/facebook/lexical/pull/6693

I'm going to keep the dependency bump and feature updates in separate
PRs unless they're breaking changes.*

**BREAKING:**

This upgrades our lexical dependencies from 0.18.0 to 0.20.0. If you
have lexical dependencies installed in your project, you will have to
upgrade those.

Additionally, the lexical team may introduce breaking changes in this
upgrade. If you use lexical APIs directly, please consult their
changelog for more information:
https://github.com/facebook/lexical/releases
2024-11-12 03:39:36 +00:00
Alessio Gravili
2ad991759f fix(ui): error in filtered relationship field component while scrolling, if the select option label is a number (#9117)
1. Open fields test suite
2. Type in relationship field, that has a relation to the numbers
collection
3. Scroll

You will get an error, as the label for the entry corresponding to the
numbers collection is of type number, and it attempts to use the
.toString() method on it
2024-11-12 03:37:53 +00:00
Alessio Gravili
9c559d7304 chore: fix live-preview tests against prod (#9122)
Live preview e2e tests had no CSS when tested against prod.

For all our other tests, we have a separate test/app directory that
imports CSS. Otherwise, the root-level /app directory is used.

For live-preview, we currently always run against test/live-preview/app,
that has no CSS import.

This PR adds a new test/live-preview/prod/app directory that imports CSS
and is used when we run tests against prod.

In order for this to work, I had to make import map generation smarter
2024-11-11 19:28:55 -07:00
Paul
d8391389ab feat(docs): add example for customising the filename of an upload via hooks (#9124) 2024-11-12 00:59:27 +00:00
Alessio Gravili
570c610eed docs: fix queue docs examples, link to qs-esm instead of qs (#9120) 2024-11-11 23:35:51 +00:00
Jarrod Flesch
9dbf1b7279 chore: imports reInitializeDB into live-preview test suite 2024-11-11 16:48:43 -05:00
Jarrod Flesch
71db10d68f chore: stabalize live preview test suite 2024-11-11 16:24:45 -05:00
Jacob Fletcher
c96fa613bc feat!: on demand rsc (#8364)
Currently, Payload renders all custom components on initial compile of
the admin panel. This is problematic for two key reasons:
1. Custom components do not receive contextual data, i.e. fields do not
receive their field data, edit views do not receive their document data,
etc.
2. Components are unnecessarily rendered before they are used

This was initially required to support React Server Components within
the Payload Admin Panel for two key reasons:
1. Fields can be dynamically rendered within arrays, blocks, etc.
2. Documents can be recursively rendered within a "drawer" UI, i.e.
relationship fields
3. Payload supports server/client component composition 

In order to achieve this, components need to be rendered on the server
and passed as "slots" to the client. Currently, the pattern for this is
to render custom server components in the "client config". Then when a
view or field is needed to be rendered, we first check the client config
for a "pre-rendered" component, otherwise render our client-side
fallback component.

But for the reasons listed above, this pattern doesn't exactly make
custom server components very useful within the Payload Admin Panel,
which is where this PR comes in. Now, instead of pre-rendering all
components on initial compile, we're able to render custom components
_on demand_, only as they are needed.

To achieve this, we've established [this
pattern](https://github.com/payloadcms/payload/pull/8481) of React
Server Functions in the Payload Admin Panel. With Server Functions, we
can iterate the Payload Config and return JSX through React's
`text/x-component` content-type. This means we're able to pass
contextual props to custom components, such as data for fields and
views.

## Breaking Changes

1. Add the following to your root layout file, typically located at
`(app)/(payload)/layout.tsx`:

    ```diff
    /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
    /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
    + import type { ServerFunctionClient } from 'payload'

    import config from '@payload-config'
    import { RootLayout } from '@payloadcms/next/layouts'
    import { handleServerFunctions } from '@payloadcms/next/utilities'
    import React from 'react'

    import { importMap } from './admin/importMap.js'
    import './custom.scss'

    type Args = {
      children: React.ReactNode
    }

+ const serverFunctions: ServerFunctionClient = async function (args) {
    +  'use server'
    +  return handleServerFunctions({
    +    ...args,
    +    config,
    +    importMap,
    +  })
    + }

    const Layout = ({ children }: Args) => (
      <RootLayout
        config={config}
        importMap={importMap}
    +  serverFunctions={serverFunctions}
      >
        {children}
      </RootLayout>
    )

    export default Layout
    ```

2. If you were previously posting to the `/api/form-state` endpoint, it
no longer exists. Instead, you'll need to invoke the `form-state` Server
Function, which can be done through the _new_ `getFormState` utility:

    ```diff
    - import { getFormState } from '@payloadcms/ui'
    - const { state } = await getFormState({
    -   apiRoute: '',
    -   body: {
    -     // ...
    -   },
    -   serverURL: ''
    - })

    + const { getFormState } = useServerFunctions()
    +
    + const { state } = await getFormState({
    +   // ...
    + })
    ```

## Breaking Changes

```diff
- useFieldProps()
- useCellProps()
```

More details coming soon.

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
Co-authored-by: James <james@trbl.design>
2024-11-11 13:59:05 -05:00
Jessica Chowdhury
3e954f45c7 fix: empty publish dropdown when localization is false (#9106)
Closes https://github.com/payloadcms/payload/issues/9092
2024-11-11 12:19:29 -05:00
Paul
9a970d21a9 fix: custom id field not shown depending on field and db types (#9091)
Closes https://github.com/payloadcms/payload/issues/9080
2024-11-11 16:42:06 +00:00
Elliot DeNolf
8a20231d40 ci: debug publish-canary job 2024-11-11 11:21:16 -05:00
Nate
26691377d2 chore: update README.md asset URL (#9104)
Updated README asset URL

<!--

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

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

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

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

### What?
Update README asset URL for hero

### Why?
Reflect latest Payload branding

### How?
URL change for correct asset

-->
2024-11-11 10:41:48 -05:00
Paul
8201a6cacd chore(templates): remove old ecommerce template (#8916)
removes the previous v2 ecommerce template from the repo ahead of v3
launch until ecomm v3 is ready
2024-11-11 10:19:24 -05:00
Nate
d7fc944792 fix: update README with new asset, image URL (#9099) 2024-11-11 10:18:11 -05:00
1348 changed files with 57839 additions and 57974 deletions

View File

@@ -28,3 +28,6 @@ fb7d1be2f3325d076b7c967b1730afcef37922c2
# Prettier and lint remaining db packages
7fd736ea5b2e9fc4ef936e9dc9e5e3d722f6d8bf
# Bump all eslint deps, lint and format
03291472d6e427ff94e61fca0616cca7796a3a95

View File

@@ -525,6 +525,7 @@ jobs:
publish-canary:
name: Publish Canary
runs-on: ubuntu-latest
if: ${{ needs.all-green.result == 'success' && github.ref_name == 'beta' }}
needs:
- all-green
@@ -533,4 +534,3 @@ jobs:
- run: |
echo github.ref: ${{ github.ref }}
echo isBeta: ${{ github.ref == 'refs/heads/beta' }}
echo isMain: ${{ github.ref == 'refs/heads/main' }}

View File

@@ -1,4 +1,4 @@
<a href="https://payloadcms.com"><img width="100%" src="https://github.com/payloadcms/payload/blob/main/packages/payload/src/admin/assets/images/github-banner-alt.jpg?raw=true" alt="Payload headless CMS Admin panel built with React" /></a>
<a href="https://payloadcms.com"><img width="100%" src="https://github.com/payloadcms/payload/blob/beta/packages/payload/src/assets/images/github-banner-nextjs-native.jpg" alt="Payload headless CMS Admin panel built with React" /></a>
<br />
<br />
@@ -7,6 +7,8 @@
&nbsp;
<a href="https://discord.gg/payload"><img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord&color=7289da&style=flat-square" /></a>
&nbsp;
<a href="https://www.npmjs.com/package/payload"><img alt="npm" src="https://img.shields.io/npm/dw/payload?style=flat-square" /></a>
&nbsp;
<a href="https://www.npmjs.com/package/payload"><img alt="npm" src="https://img.shields.io/npm/v/payload?style=flat-square" /></a>
&nbsp;
<a href="https://twitter.com/payloadcms"><img src="https://img.shields.io/badge/follow-payloadcms-1DA1F2?logo=twitter&style=flat-square" alt="Payload Twitter" /></a>
@@ -52,10 +54,6 @@ Jumpstart your next project by starting with a pre-made template. These are prod
Build any kind of website, blog, or portfolio from small to enterprise. Comes with a fully functional front-end built with RSCs and Tailwind.
### [🛒 E-Commerce](https://github.com/payloadcms/payload/tree/beta/templates/ecommerce)
Eliminate the need to combine Shopify and a CMS, and instead do it all with Payload + Stripe. Comes with a beautiful, fully functional front-end complete with shopping cart, checkout, orders, and much more.
We're constantly adding more templates to our [Templates Directory](https://github.com/payloadcms/payload/tree/beta/templates). If you maintain your own template, consider adding the `payload-template` topic to your GitHub repository for others to find.
- [Official Templates](https://github.com/payloadcms/payload/tree/beta/templates)

View File

@@ -1,8 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import configPromise from '@payload-config'
import { RootLayout } from '@payloadcms/next/layouts'
// import '@payloadcms/ui/styles.css' // Uncomment this line if `@payloadcms/ui` in `tsconfig.json` points to `/ui/dist` instead of `/ui/src`
import type { ServerFunctionClient } from 'payload'
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
@@ -12,8 +14,17 @@ type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={configPromise} importMap={importMap}>
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)

View File

@@ -31,7 +31,7 @@ The following options are available:
| **`hidden`** | Set to true or a function, called with the current user, returning true to exclude this Collection from navigation and admin routing. |
| **`hooks`** | Admin-specific hooks for this Collection. [More details](../hooks/collections). |
| **`useAsTitle`** | Specify a top-level field to use for a document title throughout the Admin Panel. If no field is defined, the ID of the document is used as the title. A field with `virtual: true` cannot be used as the title. |
| **`description`** | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#components). |
| **`description`** | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#custom-components). |
| **`defaultColumns`** | Array of field names that correspond to which columns to show by default in this Collection's List View. |
| **`hideAPIURL`** | Hides the "API URL" meta field while editing documents within this Collection. |
| **`enableRichTextLink`** | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
@@ -39,11 +39,12 @@ The following options are available:
| **`meta`** | Page metadata overrides to apply to this Collection within the Admin Panel. [More details](./metadata). |
| **`preview`** | Function to generate preview URLs within the Admin Panel that can point to your app. [More details](#preview). |
| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
| **`components`** | Swap in your own React components to be used within this Collection. [More details](#components). |
| **`components`** | Swap in your own React components to be used within this Collection. [More details](#custom-components). |
| **`listSearchableFields`** | Specify which fields should be searched in the List search view. [More details](#list-searchable-fields). |
| **`pagination`** | Set pagination-specific options for this Collection. [More details](#pagination). |
| **`baseListFilter`** | You can define a default base filter for this collection's List view, which will be merged into any filters that the user performs. |
### Components
### Custom Components
Collections can set their own [Custom Components](./components) which only apply to [Collection](../configuration/collections)-specific UI within the [Admin Panel](./overview). This includes elements such as the Save Button, or entire layouts such as the Edit View.

View File

@@ -6,7 +6,7 @@ desc: Fully customize your Admin Panel by swapping in your own React components.
keywords: admin, components, custom, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Payload [Admin Panel](./overview) is designed to be as minimal and straightforward as possible to allow for both easy customization and full control over the UI. In order for Payload to support this level of customization, Payload provides a pattern for you to supply your own React components through your [Payload Config](../configuration/overview).
The Payload [Admin Panel](./overview) is designed to be as minimal and straightforward as possible to allow for easy customization and full control over the UI. In order for Payload to support this level of customization, Payload provides a pattern for you to supply your own React components through your [Payload Config](../configuration/overview).
All Custom Components in Payload are [React Server Components](https://react.dev/reference/rsc/server-components) by default, with the exception of [Custom Providers](#custom-providers). This enables the use of the [Local API](../local-api/overview) directly on the front-end. Custom Components are available for nearly every part of the Admin Panel for extreme granularity and control.
@@ -18,51 +18,45 @@ All Custom Components in Payload are [React Server Components](https://react.dev
There are four main types of Custom Components in Payload:
- [Root Components](#root-components)
- [Collection Components](./collections#components)
- [Global Components](./globals#components)
- [Field Components](./fields)
- [Collection Components](./collections#custom-components)
- [Global Components](./globals#custom-components)
- [Field Components](./fields#custom-components)
To swap in your own Custom Component, consult the list of available components. Determine the scope that corresponds to what you are trying to accomplish, then [author your React component(s)](#building-custom-components) accordingly.
To swap in your own Custom Component, first consult the list of available components, determine the scope that corresponds to what you are trying to accomplish, then [author your React component(s)](#building-custom-components) accordingly.
## Defining Custom Components
## Defining Custom Components in the Payload Config
As Payload compiles the Admin Panel, it checks your config for Custom Components. When detected, Payload either replaces its own default component with yours, or if none exists by default, renders yours outright. While are many places where Custom Components are supported in Payload, each is defined in the same way using [Component Paths](#component-paths).
In the Payload Config, you can define custom React Components to enhance the admin interface. However, these components should not be imported directly into the server-only Payload Config to avoid including client-side code. Instead, you specify the path to the component. Heres how you can do it:
To add a Custom Component, point to its file path in your Payload Config:
src/components/Logout.tsx
```tsx
'use client'
import React from 'react'
export const MyComponent = () => {
return (
<button>Click me!</button>
)
}
```
payload.config.ts:
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
admin: { // highlight-line
admin: {
components: {
logout: {
Button: '/src/components/Logout#MyComponent'
Button: '/src/components/Logout#MyComponent' // highlight-line
}
}
},
})
```
In the path `/src/components/Logout#MyComponent`, `/src/components/Logout` is the file path, and `MyComponent` is the named export. If the component is the default export, the export name can be omitted. Path and export name are separated by a `#`.
<Banner type="success">
<strong>Note:</strong>
All Custom Components can be either Server Components or Client Components, depending on the presence of the `use client` directive at the top of the file.
</Banner>
### Configuring the Base Directory
### Component Paths
Component paths, by default, are relative to your working directory - this is usually where your Next.js config lies. To simplify component paths, you have the option to configure the *base directory* using the `admin.baseDir.baseDir` property:
In order to ensure the Payload Config is fully Node.js compatible and as lightweight as possible, components are not directly imported into your config. Instead, they are identified by their file path for the Admin Panel to resolve on its own.
Component Paths, by default, are relative to your project's base directory. This is either your current working directory, or the directory specified in `config.admin.baseDir`. To simplify Component Paths, you can also configure the base directory using the `admin.importMap.baseDir` property.
Components using named exports are identified either by appending `#` followed by the export name, or using the `exportName` property. If the component is the default export, this can be omitted.
```ts
import { buildConfig } from 'payload'
@@ -73,137 +67,72 @@ const dirname = path.dirname(filename)
const config = buildConfig({
// ...
admin: { // highlight-line
importMap: {
baseDir: path.resolve(dirname, 'src'),
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'), // highlight-line
},
components: {
logout: {
Button: '/components/Logout#MyComponent'
Button: '/components/Logout#MyComponent' // highlight-line
}
}
},
})
```
In this example, we set the base directory to the `src` directory - thus we can omit the `/src/` part of our component path string.
In this example, we set the base directory to the `src` directory, and omit the `/src/` part of our component path string.
### Passing Props
### Config Options
Each React Component in the Payload Config is typed as `PayloadComponent`. This usually is a string, but can also be an object containing the following properties:
While Custom Components are usually defined as a string, you can also pass in an object with additional options:
| Property | Description |
|---------------|-------------------------------------------------------------------------------------------------------------------------------|
| `clientProps` | Props to be passed to the React Component if it's a Client Component |
| `exportName` | Instead of declaring named exports using `#` in the component path, you can also omit them from `path` and pass them in here. |
| `path` | Path to the React Component. Named exports can be appended to the end of the path, separated by a `#` |
| `serverProps` | Props to be passed to the React Component if it's a Server Component |
To pass in props from the config, you can use the `clientProps` and/or `serverProps` properties. This alleviates the need to use an HOC (Higher-Order-Component) to declare a React Component with props passed in.
Here is an example:
src/components/Logout.tsx
```tsx
'use client'
import React from 'react'
export const MyComponent = ({ text }: { text: string }) => {
return (
<button>Click me! {text}</button>
)
}
```
payload.config.ts:
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
admin: { // highlight-line
admin: {
components: {
logout: {
// highlight-start
Button: {
path: '/src/components/Logout',
clientProps: {
text: 'Some Text.'
},
exportName: 'MyComponent'
exportName: 'MyComponent',
}
// highlight-end
}
}
},
})
```
### Import Maps
The following options are available:
It's essential to understand how `PayloadComponent` paths function behind the scenes. Directly importing React Components into your Payload Config using import statements can introduce client-only modules like CSS into your server-only config. This could error when attempting to load the Payload Config in server-only environments and unnecessarily increase the size of the Payload Config, which should remain streamlined and efficient for server use.
| Property | Description |
|---------------|-------------------------------------------------------------------------------------------------------------------------------|
| **`clientProps`** | Props to be passed to the Custom Components if it's a Client Component. [More details](#custom-props). |
| **`exportName`** | Instead of declaring named exports using `#` in the component path, you can also omit them from `path` and pass them in here. |
| **`path`** | File path to the Custom Component. Named exports can be appended to the end of the path, separated by a `#`. |
| **`serverProps`** | Props to be passed to the Custom Component if it's a Server Component. [More details](#custom-props). |
Instead, we utilize component paths to reference React Components. This method enhances the Payload Config with actual React Component imports on the client side, without affecting server-side usage. A script is deployed to scan the Payload Config, collecting all component paths and creating an `importMap.js`. This file, located in app/(payload)/admin/importMap.js, must be statically imported by your Next.js root page and layout. The script imports all the React Components from the specified paths into a Map, associating them with their respective paths (the ones you defined).
For more details on how to build Custom Components, see [Building Custom Components](#building-custom-components).
When constructing the `ClientConfig`, Payload uses the component paths as keys to fetch the corresponding React Component imports from the Import Map. It then substitutes the `PayloadComponent` with a `MappedComponent`. A `MappedComponent` includes the React Component and additional metadata, such as whether it's a server or a client component and which props it should receive. These components are then rendered through the `<RenderComponent />` component within the Payload Admin Panel.
### Import Map
Import maps are regenerated whenever you modify any element related to component paths. This regeneration occurs at startup and whenever Hot Module Replacement (HMR) runs. If the import maps fail to regenerate during HMR, you can restart your application and execute the `payload generate:importmap` command to manually create a new import map. If you encounter any errors running this command, see the [Troubleshooting](../local-api/outside-nextjs#troubleshooting) section.
In order for Payload to make use of [Component Paths](#component-paths), an "Import Map" is automatically generated at `app/(payload)/admin/importMap.js`. This file contains every Custom Component in your config, keyed to their respective paths. When Payload needs to lookup a component, it uses this file to find the correct import.
### Component paths in external packages
The Import Map is automatically regenerated at startup and whenever Hot Module Replacement (HMR) runs, or you can run `payload generate:importmap` to manually regenerate it.
Component paths are resolved relative to your project's base directory, which is either your current working directory or the directory specified in `config.admin.baseDir`. When using custom components from external packages, you can't use relative paths. Instead, use an import path that's accessible as if you were writing an import statement in your project's base directory.
#### Custom Imports
For example, to export a field with a custom component from an external package named `my-external-package`:
If needed, custom items can be appended onto the Import Map. This is mostly only relevant for plugin authors who need to add a custom import that is not referenced in a known location.
To add a custom import to the Import Map, use the `admin.dependencies` property in your [Payload Config](../getting-started/overview):
```ts
import type { Field } from 'payload'
export const MyCustomField: Field = {
type: 'text',
name: 'MyField',
admin: {
components: {
Field: 'my-external-package/client#MyFieldComponent'
}
}
}
```
import { buildConfig } from 'payload'
Despite `MyFieldComponent` living in `src/components/MyFieldComponent.tsx` in `my-external-package`, this will not be accessible from the consuming project. Instead, we recommend exporting all custom components from one file in the external package. For example, you can define a `src/client.ts file in `my-external-package`:
```ts
'use client'
export { MyFieldComponent } from './components/MyFieldComponent'
```
Then, update the package.json of `my-external-package:
```json
{
...
"exports": {
"./client": {
"import": "./dist/client.js",
"types": "./dist/client.d.ts",
"default": "./dist/client.js"
}
}
}
```
This setup allows you to specify the component path as `my-external-package/client#MyFieldComponent` as seen above. The import map will generate:
```ts
import { MyFieldComponent } from 'my-external-package/client'
```
which is a valid way to access MyFieldComponent that can be resolved by the consuming project.
### Custom Components from unknown locations
By default, any component paths from known locations are added to the import map. However, if you need to add any components from unknown locations to the import map, you can do so by adding them to the `admin.dependencies` array in your Payload Config. This is mostly only relevant for plugin authors and not for regular Payload users.
Example:
```ts
export default {
export default buildConfig({
// ...
admin: {
// ...
@@ -220,117 +149,12 @@ export default {
}
```
This way, `TestComponent` is added to the import map, no matter if it's referenced in a known location or not. On the client, you can then use the component like this:
```tsx
'use client'
import { RenderComponent, useConfig } from '@payloadcms/ui'
import React from 'react'
export const CustomView = () => {
const { config } = useConfig()
return (
<div>
<RenderComponent mappedComponent={config.admin.dependencies?.myTestComponent} />
</div>
)
}
```
## Root Components
Root Components are those that effect the [Admin Panel](./overview) generally, such as the logo or the main nav.
To override Root Components, use the `admin.components` property in your [Payload Config](../getting-started/overview):
```ts
import { buildConfig } from 'payload'
export default buildConfig({
// ...
admin: {
// highlight-start
components: {
// ...
},
// highlight-end
},
})
```
_For details on how to build Custom Components, see [Building Custom Components](#building-custom-components)._
The following options are available:
| Path | Description |
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`Nav`** | Contains the sidebar / mobile menu in its entirety. |
| **`beforeNavLinks`** | An array of Custom Components to inject into the built-in Nav, _before_ the links themselves. |
| **`afterNavLinks`** | An array of Custom Components to inject into the built-in Nav, _after_ the links. |
| **`beforeDashboard`** | An array of Custom Components to inject into the built-in Dashboard, _before_ the default dashboard contents. |
| **`afterDashboard`** | An array of Custom Components to inject into the built-in Dashboard, _after_ the default dashboard contents. |
| **`beforeLogin`** | An array of Custom Components to inject into the built-in Login, _before_ the default login form. |
| **`afterLogin`** | An array of Custom Components to inject into the built-in Login, _after_ the default login form. |
| **`logout.Button`** | The button displayed in the sidebar that logs the user out. |
| **`graphics.Icon`** | The simplified logo used in contexts like the the `Nav` component. |
| **`graphics.Logo`** | The full logo used in contexts like the `Login` view. |
| **`providers`** | Custom [React Context](https://react.dev/learn/scaling-up-with-reducer-and-context) providers that will wrap the entire Admin Panel. [More details](#custom-providers). |
| **`actions`** | An array of Custom Components to be rendered _within_ the header of the Admin Panel, providing additional interactivity and functionality. |
| **`header`** | An array of Custom Components to be injected above the Payload header. |
| **`views`** | Override or create new views within the Admin Panel. [More details](./views). |
<Banner type="success">
<strong>Note:</strong>
You can also use set [Collection Components](./collections#components) and [Global Components](./globals#components) in their respective configs.
</Banner>
### Custom Providers
As you add more and more Custom Components to your [Admin Panel](./overview), you may find it helpful to add additional [React Context](https://react.dev/learn/scaling-up-with-reducer-and-context)(s). Payload allows you to inject your own context providers in your app so you can export your own custom hooks, etc.
To add a Custom Provider, use the `admin.components.providers` property in your [Payload Config](../getting-started/overview):
```ts
import { buildConfig } from 'payload'
export default buildConfig({
// ...
admin: {
components: {
providers: ['/path/to/MyProvider'], // highlight-line
},
},
})
```
Then build your Custom Provider as follows:
```tsx
'use client'
import React, { createContext, useContext } from 'react'
const MyCustomContext = React.createContext(myCustomValue)
export const MyProvider: React.FC = ({ children }) => {
return (
<MyCustomContext.Provider value={myCustomValue}>
{children}
</MyCustomContext.Provider>
)
}
export const useMyCustomContext = () => useContext(MyCustomContext)
```
<Banner type="warning">
<strong>Reminder:</strong> Custom Providers are by definition Client Components. This means they must include the `use client` directive at the top of their files and cannot use server-only code.
</Banner>
## Building Custom Components
All Custom Components in Payload are [React Server Components](https://react.dev/reference/rsc/server-components) by default, with the exception of [Custom Providers](#custom-providers). This enables the use of the [Local API](../local-api/overview) directly on the front-end, among other things.
### Default Props
To make building Custom Components as easy as possible, Payload automatically provides common props, such as the [`payload`](../local-api/overview) class and the [`i18n`](../configuration/i18n) object. This means that when building Custom Components within the Admin Panel, you do not have to get these yourself.
Here is an example:
@@ -359,12 +183,46 @@ Each Custom Component receives the following props by default:
| `payload` | The [Payload](../local-api/overview) class. |
| `i18n` | The [i18n](../configuration/i18n) object. |
Custom Components also receive various other props that are specific to the context in which the Custom Component is being rendered. For example, [Custom Views](./views) receive the `user` prop. For a full list of available props, consult the documentation related to the specific component you are working with.
<Banner type="success">
See [Root Components](#root-components), [Collection Components](#collection-components), [Global Components](#global-components), or [Field Components](#custom-field-components) for a complete list of all available components.
<Banner type="warning">
<strong>Reminder:</strong>
All Custom Components also receive various other props that are specific component being rendered. See [Root Components](#root-components), [Collection Components](#collection-components), [Global Components](#global-components), or [Field Components](#custom-field-components) for a complete list of all default props per component.
</Banner>
### Custom Props
To pass in custom props from the config, you can use either the `clientProps` or `serverProps` properties depending on whether your prop is [serializable](https://react.dev/reference/rsc/use-client#serializable-types), and whether your component is a Server or Client Component.
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
admin: { // highlight-line
components: {
logout: {
Button: {
path: '/src/components/Logout#MyComponent',
clientProps: {
myCustomProp: 'Hello, World!' // highlight-line
},
}
}
}
},
})
```
```tsx
'use client'
import React from 'react'
export const MyComponent = ({ myCustomProp }: { myCustomProp: string }) => {
return (
<button>{myCustomProp}</button>
)
}
```
### Client Components
When [Building Custom Components](#building-custom-components), it's still possible to use client-side code such as `useState` or the `window` object. To do this, simply add the `use client` directive at the top of your file. Payload will automatically detect and remove all default, [non-serializable props](https://react.dev/reference/rsc/use-client#serializable-types) before rendering your component.
@@ -414,6 +272,7 @@ But, the Payload Config is [non-serializable](https://react.dev/reference/rsc/us
For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](./hooks#useconfig) hook:
```tsx
'use client'
import React from 'react'
import { useConfig } from '@payloadcms/ui'
@@ -432,14 +291,13 @@ export const MyClientComponent: React.FC = () => {
See [Using Hooks](#using-hooks) for more details.
</Banner>
All [Field Components](./fields) automatically receive their respective Field Config through a common [`field`](./fields#the-field-prop) prop:
All [Field Components](./fields) automatically receive their respective Field Config through props.
```tsx
'use client'
import React from 'react'
import type { TextFieldClientComponent } from 'payload'
import type { TextFieldServerComponent } from 'payload'
export const MyClientFieldComponent: TextFieldClientComponent = ({ field: { name } }) => {
export const MyClientFieldComponent: TextFieldServerComponent = ({ field: { name } }) => {
return (
<p>
{`This field's name is ${name}`}
@@ -448,28 +306,6 @@ export const MyClientFieldComponent: TextFieldClientComponent = ({ field: { name
}
```
### Using Hooks
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](./hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts:
```tsx
'use client'
import React from 'react'
import { useDocumentInfo } from '@payloadcms/ui'
export const MyClientComponent: React.FC = () => {
const { slug } = useDocumentInfo() // highlight-line
return (
<p>{`Entity slug: ${slug}`}</p>
)
}
```
<Banner type="success">
See the [Hooks](./hooks) documentation for a full list of available hooks.
</Banner>
### Getting the Current Language
All Custom Components can support multiple languages to be consistent with Payload's [Internationalization](../configuration/i18n). To do this, first add your translation resources to the [I18n Config](../configuration/i18n).
@@ -492,6 +328,7 @@ export default async function MyServerComponent({ i18n }) {
The best way to do this within a Client Component is to import the `useTranslation` hook from `@payloadcms/ui`:
```tsx
'use client'
import React from 'react'
import { useTranslation } from '@payloadcms/ui'
@@ -535,6 +372,7 @@ export default async function MyServerComponent({ payload, locale }) {
The best way to do this within a Client Component is to import the `useLocale` hook from `@payloadcms/ui`:
```tsx
'use client'
import React from 'react'
import { useLocale } from '@payloadcms/ui'
@@ -556,7 +394,29 @@ const Greeting: React.FC = () => {
See the [Hooks](./hooks) documentation for a full list of available hooks.
</Banner>
### Styling Custom Components
### Using Hooks
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](./hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts. To do this, you can one of the many hooks available depending on your needs.
```tsx
'use client'
import React from 'react'
import { useDocumentInfo } from '@payloadcms/ui'
export const MyClientComponent: React.FC = () => {
const { slug } = useDocumentInfo() // highlight-line
return (
<p>{`Entity slug: ${slug}`}</p>
)
}
```
<Banner type="success">
See the [Hooks](./hooks) documentation for a full list of available hooks.
</Banner>
### Adding Styles
Payload has a robust [CSS Library](./customizing-css) that you can use to style your Custom Components similarly to Payload's built-in styling. This will ensure that your Custom Components match the existing design system, and so that they automatically adapt to any theme changes that might occur.
@@ -592,10 +452,99 @@ Payload also exports its [SCSS](https://sass-lang.com) library for reuse which i
background-color: var(--theme-elevation-900);
}
}
```
<Banner type="success">
<strong>Note:</strong>
You can also drill into Payload's own component styles, or easily apply global, app-wide CSS. More on that [here](./customizing-css).
</Banner>
## Root Components
Root Components are those that effect the [Admin Panel](./overview) generally, such as the logo or the main nav.
To override Root Components, use the `admin.components` property in your [Payload Config](../getting-started/overview):
```ts
import { buildConfig } from 'payload'
export default buildConfig({
// ...
admin: {
// highlight-start
components: {
// ...
},
// highlight-end
},
})
```
_For details on how to build Custom Components, see [Building Custom Components](#building-custom-components)._
The following options are available:
| Path | Description |
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`Nav`** | Contains the sidebar / mobile menu in its entirety. |
| **`beforeNavLinks`** | An array of Custom Components to inject into the built-in Nav, _before_ the links themselves. |
| **`afterNavLinks`** | An array of Custom Components to inject into the built-in Nav, _after_ the links. |
| **`beforeDashboard`** | An array of Custom Components to inject into the built-in Dashboard, _before_ the default dashboard contents. |
| **`afterDashboard`** | An array of Custom Components to inject into the built-in Dashboard, _after_ the default dashboard contents. |
| **`beforeLogin`** | An array of Custom Components to inject into the built-in Login, _before_ the default login form. |
| **`afterLogin`** | An array of Custom Components to inject into the built-in Login, _after_ the default login form. |
| **`logout.Button`** | The button displayed in the sidebar that logs the user out. |
| **`graphics.Icon`** | The simplified logo used in contexts like the the `Nav` component. |
| **`graphics.Logo`** | The full logo used in contexts like the `Login` view. |
| **`providers`** | Custom [React Context](https://react.dev/learn/scaling-up-with-reducer-and-context) providers that will wrap the entire Admin Panel. [More details](#custom-providers). |
| **`actions`** | An array of Custom Components to be rendered _within_ the header of the Admin Panel, providing additional interactivity and functionality. |
| **`header`** | An array of Custom Components to be injected above the Payload header. |
| **`views`** | Override or create new views within the Admin Panel. [More details](./views). |
<Banner type="success">
<strong>Note:</strong>
You can also use set [Collection Components](./collections#custom-components) and [Global Components](./globals#custom-components) in their respective configs.
</Banner>
### Custom Providers
As you add more and more Custom Components to your [Admin Panel](./overview), you may find it helpful to add additional [React Context](https://react.dev/learn/scaling-up-with-reducer-and-context)(s). Payload allows you to inject your own context providers in your app so you can export your own custom hooks, etc.
To add a Custom Provider, use the `admin.components.providers` property in your [Payload Config](../getting-started/overview):
```ts
import { buildConfig } from 'payload'
export default buildConfig({
// ...
admin: {
components: {
providers: ['/path/to/MyProvider'], // highlight-line
},
},
})
```
Then build your Custom Provider as follows:
```tsx
'use client'
import React, { createContext, useContext } from 'react'
const MyCustomContext = React.createContext(myCustomValue)
export const MyProvider: React.FC = ({ children }) => {
return (
<MyCustomContext.Provider value={myCustomValue}>
{children}
</MyCustomContext.Provider>
)
}
export const useMyCustomContext = () => useContext(MyCustomContext)
```
<Banner type="warning">
<strong>Reminder:</strong> Custom Providers are by definition Client Components. This means they must include the `use client` directive at the top of their files and cannot use server-only code.
</Banner>

View File

@@ -6,7 +6,7 @@ desc:
keywords:
---
[Fields](../fields/overview) within the [Admin Panel](./overview) can be endlessly customized in their appearance and behavior without affecting their underlying data structure. Fields are designed to withstand heavy modification or even complete replacement through the use of [Custom Field Components](#field-components), [Conditional Logic](#conditional-logic), [Custom Validations](../fields/overview#validation), and more.
[Fields](../fields/overview) within the [Admin Panel](./overview) can be endlessly customized in their appearance and behavior without affecting their underlying data structure. Fields are designed to withstand heavy modification or even complete replacement through the use of [Custom Field Components](#custom-components), [Conditional Logic](#conditional-logic), [Custom Validations](../fields/overview#validation), and more.
For example, your app might need to render a specific interface that Payload does not inherently support, such as a color picker. To do this, you could replace the default [Text Field](../fields/text) input with your own user-friendly component that formats the data into a valid color value.
@@ -56,335 +56,7 @@ The following options are available:
| **`disableListFilter`** | Set `disableListFilter` to `true` to prevent fields from appearing in the list view filter options. |
| **`hidden`** | Will transform the field into a `hidden` input type. Its value will still submit with requests in the Admin Panel, but the field itself will not be visible to editors. |
## Field Components
Within the [Admin Panel](./overview), fields are rendered in three distinct places:
- [Field](#the-field-component) - The actual form field rendered in the Edit View.
- [Cell](#the-cell-component) - The table cell component rendered in the List View.
- [Filter](#the-filter-component) - The filter component rendered in the List View.
To easily swap in Field Components with your own, use the `admin.components` property in your [Field Config](../fields/overview):
```ts
import type { CollectionConfig } from 'payload'
export const CollectionConfig: CollectionConfig = {
// ...
fields: [
// ...
{
// ...
admin: {
components: { // highlight-line
// ...
},
},
}
]
}
```
The following options are available:
| Component | Description |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- |
| **`Field`** | The form field rendered of the Edit View. [More details](#the-field-component). |
| **`Cell`** | The table cell rendered of the List View. [More details](#the-cell-component). |
| **`Filter`** | The filter component rendered in the List View. [More details](#the-filter-component). || Component | Description |
| **`Label`** | Override the default Label of the Field Component. [More details](#the-label-component). |
| **`Error`** | Override the default Error of the Field Component. [More details](#the-error-component). |
| **`Description`** | Override the default Description of the Field Component. [More details](#the-description-component). |
| **`beforeInput`** | An array of elements that will be added before the input of the Field Component. [More details](#afterinput-and-beforeinput).|
| **`afterInput`** | An array of elements that will be added after the input of the Field Component. [More details](#afterinput-and-beforeinput). |
_\* **`beforeInput`** and **`afterInput`** are only supported in fields that do not contain other fields, such as [`Text`](../fields/text), and [`Textarea`](../fields/textarea)._
### The Field Component
The Field Component is the actual form field rendered in the Edit View. This is the input that user's will interact with when editing a document.
To easily swap in your own Field Component, use the `admin.components.Field` property in your [Field Config](../fields/overview):
```ts
import type { CollectionConfig } from 'payload'
export const CollectionConfig: CollectionConfig = {
// ...
fields: [
// ...
{
// ...
admin: {
components: {
Field: '/path/to/MyFieldComponent', // highlight-line
},
},
}
]
}
```
_For details on how to build Custom Components, see [Building Custom Components](./components#building-custom-components)._
<Banner type="warning">
Instead of replacing the entire Field Component, you can alternately replace or slot-in only specific parts by using the [`Label`](#the-label-component), [`Error`](#the-error-component), [`beforeInput`](#afterinput-and-beforinput), and [`afterInput`](#afterinput-and-beforinput) properties.
</Banner>
All Field Components receive the following props:
| Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`docPreferences`** | An object that contains the [Preferences](./preferences) for the document.
| **`field`** | In Server Components, this is the original Field Config. In Client Components, this is the sanitized Client Field Config. [More details](#the-field-prop). |
| **`clientField`** | Server components receive the Client Field Config through this prop. [More details](#the-field-prop). |
| **`locale`** | The locale of the field. [More details](../configuration/localization). |
| **`readOnly`** | A boolean value that represents if the field is read-only or not. |
| **`user`** | The currently authenticated user. [More details](../authentication/overview). |
| **`validate`** | A function that can be used to validate the field. |
<Banner type="success">
<strong>Reminder:</strong>
All [Custom Server Components](./components) receive the `payload` and `i18n` properties by default. See [Building Custom Components](./components#building-custom-components) for more details.
</Banner>
#### Sending and receiving values from the form
When swapping out the `Field` component, you are responsible for sending and receiving the field's `value` from the form itself.
To do so, import the [`useField`](./hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value:
```tsx
'use client'
import { useField } from '@payloadcms/ui'
export const CustomTextField: React.FC = () => {
const { value, setValue } = useField() // highlight-line
return (
<input
onChange={(e) => setValue(e.target.value)}
value={value}
/>
)
}
```
<Banner type="success">
For a complete list of all available React hooks, see the [Payload React Hooks](./hooks) documentation. For additional help, see [Building Custom Components](./components#building-custom-components).
</Banner>
#### TypeScript
When building Custom Field Components, you can import the component type to ensure type safety. There is an explicit type for the Field Component, one for every [Field Type](../fields/overview) and for every client/server environment. The convention is to prepend the field type onto the target type, i.e. `TextFieldClientComponent`:
```tsx
import type {
TextFieldClientComponent,
TextFieldServerComponent,
TextFieldClientProps,
TextFieldServerProps,
// ...and so on for each Field Type
} from 'payload'
```
### The `field` Prop
All Field Components are passed their own Field Config through a common `field` prop. Within Server Components, this is the original Field Config as written within your Payload Config. Within Client Components, however, this is a "Client Config", which is a sanitized, client-friendly version of the Field Config. This is because the original Field Config is [non-serializable](https://react.dev/reference/rsc/use-client#serializable-types), meaning it cannot be passed into Client Components without first being transformed.
The Client Field Config is an exact copy of the original Field Config, minus all non-serializable properties, plus all evaluated functions such as field labels, [Custom Components](../components), etc.
Server Component:
```tsx
import React from 'react'
import type { TextFieldServerComponent } from 'payload'
import { TextField } from '@payloadcms/ui'
export const MyServerField: TextFieldServerComponent = ({ clientField }) => {
return <TextField field={clientField} />
}
```
<Banner type="info">
<strong>Tip:</strong>
Server Components can still access the original Field Config through the `field` prop.
</Banner>
Client Component:
```tsx
'use client'
import React from 'react'
import type { TextFieldClientComponent } from 'payload'
import { TextField } from '@payloadcms/ui'
export const MyTextField: TextFieldClientComponent = ({ field }) => {
return <TextField field={field} />
}
```
The following additional properties are also provided to the `field` prop:
| Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`_isPresentational`** | A boolean indicating that the field is purely visual and does not directly affect data or change data shape, i.e. the [UI Field](../fields/ui). |
| **`_path`** | A string representing the direct, dynamic path to the field at runtime, i.e. `myGroup.myArray[0].myField`. |
| **`_schemaPath`** | A string representing the direct, static path to the [Field Config](../fields/overview), i.e. `myGroup.myArray.myField` |
<Banner type="info">
<strong>Note:</strong>
These properties are underscored to denote that they are not part of the original Field Config, and instead are attached during client sanitization to make fields easier to work with on the front-end.
</Banner>
#### TypeScript
When building Custom Field Components, you can import the client field props to ensure type safety in your component. There is an explicit type for the Field Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to prepend the field type onto the target type, i.e. `TextFieldClientComponent`:
```tsx
import type {
TextFieldClientComponent,
TextFieldServerComponent,
TextFieldClientProps,
TextFieldServerProps,
// ...and so on for each Field Type
} from 'payload'
```
### The Cell Component
The Cell Component is rendered in the table of the List View. It represents the value of the field when displayed in a table cell.
To easily swap in your own Cell Component, use the `admin.components.Cell` property in your [Field Config](../fields/overview):
```ts
import type { Field } from 'payload'
export const myField: Field = {
name: 'myField',
type: 'text',
admin: {
components: {
Cell: '/path/to/MyCustomCellComponent', // highlight-line
},
},
}
```
_For details on how to build Custom Components, see [Building Custom Components](./components#building-custom-components)._
All Cell Components receive the following props:
| Property | Description |
| ---------------- | ----------------------------------------------------------------- |
| **`field`** | In Server Components, this is the original Field Config. In Client Components, this is the sanitized Client Field Config. [More details](#the-field-prop). |
| **`clientField`** | Server components receive the Client Field Config through this prop. [More details](#the-field-prop). |
| **`link`** | A boolean representing whether this cell should be wrapped in a link. |
| **`onClick`** | A function that is called when the cell is clicked. |
<Banner type="info">
<strong>Tip:</strong>
Use the [`useTableCell`](./hooks#usetablecell) hook to subscribe to the field's `cellData` and `rowData`.
</Banner>
<Banner type="success">
<strong>Reminder:</strong>
All [Custom Server Components](./components) receive the `payload` and `i18n` properties by default. See [Building Custom Components](./components#building-custom-components) for more details.
</Banner>
### The Label Component
The Label Component is rendered anywhere a field needs to be represented by a label. This is typically used in the Edit View, but can also be used in the List View and elsewhere.
To easily swap in your own Label Component, use the `admin.components.Label` property in your [Field Config](../fields/overview):
```ts
import type { Field } from 'payload'
export const myField: Field = {
name: 'myField',
type: 'text',
admin: {
components: {
Label: '/path/to/MyCustomLabelComponent', // highlight-line
},
},
}
```
_For details on how to build Custom Components, see [Building Custom Components](./components#building-custom-components)._
Custom Label Components receive all [Field Component](#the-field-component) props, plus the following props:
| Property | Description |
| -------------- | ---------------------------------------------------------------- |
| **`field`** | In Server Components, this is the original Field Config. In Client Components, this is the sanitized Client Field Config. [More details](#the-field-prop). |
| **`clientField`** | Server components receive the Client Field Config through this prop. [More details](#the-field-prop). |
<Banner type="success">
<strong>Reminder:</strong>
All [Custom Server Components](./components) receive the `payload` and `i18n` properties by default. See [Building Custom Components](./components#building-custom-components) for more details.
</Banner>
#### TypeScript
When building Custom Label Components, you can import the component props to ensure type safety in your component. There is an explicit type for the Label Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to append `LabelServerComponent` or `LabelClientComponent` to the type of field, i.e. `TextFieldLabelClientComponent`.
```tsx
import type {
TextFieldLabelServerComponent,
TextFieldLabelClientComponent,
// ...and so on for each Field Type
} from 'payload'
```
### The Error Component
The Error Component is rendered when a field fails validation. It is typically displayed beneath the field input in a visually-compelling style.
To easily swap in your own Error Component, use the `admin.components.Error` property in your [Field Config](../fields/overview):
```ts
import type { Field } from 'payload'
export const myField: Field = {
name: 'myField',
type: 'text',
admin: {
components: {
Error: '/path/to/MyCustomErrorComponent', // highlight-line
},
},
}
```
_For details on how to build Custom Components, see [Building Custom Components](./components#building-custom-components)._
Custom Error Components receive all [Field Component](#the-field-component) props, plus the following props:
| Property | Description |
| --------------- | ------------------------------------------------------------- |
| **`field`** | In Server Components, this is the original Field Config. In Client Components, this is the sanitized Client Field Config. [More details](#the-field-prop). |
| **`clientField`** | Server components receive the Client Field Config through this prop. [More details](#the-field-prop). |
<Banner type="success">
<strong>Reminder:</strong>
All [Custom Server Components](./components) receive the `payload` and `i18n` properties by default. See [Building Custom Components](./components#building-custom-components) for more details.
</Banner>
#### TypeScript
When building Custom Error Components, you can import the component props to ensure type safety in your component. There is an explicit type for the Error Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to append `ErrorServerComponent` or `ErrorClientComponent` to the type of field, i.e. `TextFieldErrorClientComponent`.
```tsx
import type {
TextFieldErrorServerComponent,
TextFieldErrorClientComponent,
// And so on for each Field Type
} from 'payload'
```
### The Description Property
## Field Descriptions
Field Descriptions are used to provide additional information to the editor about a field, such as special instructions. Their placement varies from field to field, but typically are displayed with subtle style differences beneath the field inputs.
@@ -392,7 +64,7 @@ A description can be configured in three ways:
- As a string.
- As a function which returns a string. [More details](#description-functions).
- As a React component. [More details](#the-description-component).
- As a React component. [More details](#description).
To easily add a Custom Description to a field, use the `admin.description` property in your [Field Config](../fields/overview):
@@ -416,7 +88,7 @@ export const MyCollectionConfig: SanitizedCollectionConfig = {
<Banner type="warning">
<strong>Reminder:</strong>
To replace the Field Description with a [Custom Component](./components), use the `admin.components.Description` property. [More details](#the-description-component).
To replace the Field Description with a [Custom Component](./components), use the `admin.components.Description` property. [More details](#description).
</Banner>
#### Description Functions
@@ -449,89 +121,6 @@ All Description Functions receive the following arguments:
| -------------- | ---------------------------------------------------------------- |
| **`t`** | The `t` function used to internationalize the Admin Panel. [More details](../configuration/i18n) |
### The Description Component
Alternatively to the [Description Property](#the-description-property), you can also use a [Custom Component](./components) as the Field Description. This can be useful when you need to provide more complex feedback to the user, such as rendering dynamic field values or other interactive elements.
To easily add a Description Component to a field, use the `admin.components.Description` property in your [Field Config](../fields/overview):
```ts
import type { SanitizedCollectionConfig } from 'payload'
export const MyCollectionConfig: SanitizedCollectionConfig = {
// ...
fields: [
// ...
{
name: 'myField',
type: 'text',
admin: {
components: {
Description: '/path/to/MyCustomDescriptionComponent', // highlight-line
}
}
}
]
}
```
_For details on how to build a Custom Description, see [Building Custom Components](./components#building-custom-components)._
Custom Description Components receive all [Field Component](#the-field-component) props, plus the following props:
| Property | Description |
| -------------- | ---------------------------------------------------------------- |
| **`field`** | In Server Components, this is the original Field Config. In Client Components, this is the sanitized Client Field Config. [More details](#the-field-prop). |
| **`clientField`** | Server components receive the Client Field Config through this prop. [More details](#the-field-prop). |
<Banner type="success">
<strong>Reminder:</strong>
All [Custom Server Components](./components) receive the `payload` and `i18n` properties by default. See [Building Custom Components](./components#building-custom-components) for more details.
</Banner>
#### TypeScript
When building Custom Description Components, you can import the component props to ensure type safety in your component. There is an explicit type for the Description Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to append `DescriptionServerComponent` or `DescriptionClientComponent` to the type of field, i.e. `TextFieldDescriptionClientComponent`.
```tsx
import type {
TextFieldDescriptionServerComponent,
TextFieldDescriptionClientComponent,
// And so on for each Field Type
} from 'payload'
```
### afterInput and beforeInput
With these properties you can add multiple components _before_ and _after_ the input element, as their name suggests. This is useful when you need to render additional elements alongside the field without replacing the entire field component.
To add components before and after the input element, use the `admin.components.beforeInput` and `admin.components.afterInput` properties in your [Field Config](../fields/overview):
```ts
import type { SanitizedCollectionConfig } from 'payload'
export const MyCollectionConfig: SanitizedCollectionConfig = {
// ...
fields: [
// ...
{
name: 'myField',
type: 'text',
admin: {
components: {
// highlight-start
beforeInput: ['/path/to/MyCustomComponent'],
afterInput: ['/path/to/MyOtherCustomComponent'],
// highlight-end
}
}
}
]
}
```
_For details on how to build Custom Components, see [Building Custom Components](./components#building-custom-components)._
## Conditional Logic
You can show and hide fields based on what other fields are doing by utilizing conditional logic on a field by field basis. The `condition` property on a field's admin config accepts a function which takes three arguments:
@@ -570,3 +159,322 @@ The `condition` function should return a boolean that will control if the field
]
}
```
## Custom Components
Within the [Admin Panel](./overview), fields are represented in three distinct places:
- [Field](#field) - The actual form field rendered in the Edit View.
- [Cell](#cell) - The table cell component rendered in the List View.
- [Filter](#filter) - The filter component rendered in the List View.
To easily swap in Field Components with your own, use the `admin.components` property in your [Field Config](../fields/overview):
```ts
import type { CollectionConfig } from 'payload'
export const CollectionConfig: CollectionConfig = {
// ...
fields: [
// ...
{
// ...
admin: {
components: { // highlight-line
// ...
},
},
}
]
}
```
The following options are available:
| Component | Description |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- |
| **`Field`** | The form field rendered of the Edit View. [More details](#field). |
| **`Cell`** | The table cell rendered of the List View. [More details](#cell). |
| **`Filter`** | The filter component rendered in the List View. [More details](#filter). |
| **`Label`** | Override the default Label of the Field Component. [More details](#label). |
| **`Error`** | Override the default Error of the Field Component. [More details](#error). |
| **`Description`** | Override the default Description of the Field Component. [More details](#description). |
| **`beforeInput`** | An array of elements that will be added before the input of the Field Component. [More details](#afterinput-and-beforeinput).|
| **`afterInput`** | An array of elements that will be added after the input of the Field Component. [More details](#afterinput-and-beforeinput). |
### Field
The Field Component is the actual form field rendered in the Edit View. This is the input that user's will interact with when editing a document.
To easily swap in your own Field Component, use the `admin.components.Field` property in your [Field Config](../fields/overview):
```ts
import type { CollectionConfig } from 'payload'
export const CollectionConfig: CollectionConfig = {
// ...
fields: [
// ...
{
// ...
admin: {
components: {
Field: '/path/to/MyFieldComponent', // highlight-line
},
},
}
]
}
```
_For details on how to build Custom Components, see [Building Custom Components](./components#building-custom-components)._
<Banner type="warning">
Instead of replacing the entire Field Component, you can alternately replace or slot-in only specific parts by using the [`Label`](#label), [`Error`](#error), [`beforeInput`](#afterinput-and-beforinput), and [`afterInput`](#afterinput-and-beforinput) properties.
</Banner>
#### Default Props
All Field Components receive the following props by default:
| Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`docPreferences`** | An object that contains the [Preferences](./preferences) for the document.
| **`field`** | In Client Components, this is the sanitized Client Field Config. In Server Components, this is the original Field Config. Server Components will also receive the sanitized field config through the`clientField` prop (see below). |
| **`locale`** | The locale of the field. [More details](../configuration/localization). |
| **`readOnly`** | A boolean value that represents if the field is read-only or not. |
| **`user`** | The currently authenticated user. [More details](../authentication/overview). |
| **`validate`** | A function that can be used to validate the field. |
| **`path`** | A string representing the direct, dynamic path to the field at runtime, i.e. `myGroup.myArray.0.myField`. |
| **`schemaPath`** | A string representing the direct, static path to the [Field Config](../fields/overview), i.e. `posts.myGroup.myArray.myField`. |
| **`indexPath`** | A hyphen-notated string representing the path to the field _within the nearest named ancestor field_, i.e. `0-0` |
In addition to the above props, all Server Components will also receive the following props:
| Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`clientField`** | The serializable Client Field Config. |
| **`field`** | The Field Config. [More details](../fields/overview). |
| **`data`** | The current document being edited. |
| **`i18n`** | The [i18n](../configuration/i18n) object.
| **`payload`** | The [Payload](../local-api/overview) class. |
| **`permissions`** | The field permissions based on the currently authenticated user. |
| **`siblingData`** | The data of the field's siblings. |
| **`user`** | The currently authenticated user. [More details](../authentication/overview). |
| **`value`** | The value of the field at render-time. |
#### Sending and receiving values from the form
When swapping out the `Field` component, you are responsible for sending and receiving the field's `value` from the form itself.
To do so, import the [`useField`](./hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value:
```tsx
'use client'
import { useField } from '@payloadcms/ui'
export const CustomTextField: React.FC = () => {
const { value, setValue } = useField() // highlight-line
return (
<input
onChange={(e) => setValue(e.target.value)}
value={value}
/>
)
}
```
<Banner type="success">
For a complete list of all available React hooks, see the [Payload React Hooks](./hooks) documentation. For additional help, see [Building Custom Components](./components#building-custom-components).
</Banner>
#### TypeScript
When building Custom Field Components, you can import the client field props to ensure type safety in your component. There is an explicit type for the Field Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to prepend the field type onto the target type, i.e. `TextFieldClientComponent`:
```tsx
import type {
TextFieldClientComponent,
TextFieldServerComponent,
TextFieldClientProps,
TextFieldServerProps,
// ...and so on for each Field Type
} from 'payload'
```
### Cell
The Cell Component is rendered in the table of the List View. It represents the value of the field when displayed in a table cell.
To easily swap in your own Cell Component, use the `admin.components.Cell` property in your [Field Config](../fields/overview):
```ts
import type { Field } from 'payload'
export const myField: Field = {
name: 'myField',
type: 'text',
admin: {
components: {
Cell: '/path/to/MyCustomCellComponent', // highlight-line
},
},
}
```
All Cell Components receive the same [Default Field Component Props](#field), plus the following:
| Property | Description |
| ---------------- | ----------------------------------------------------------------- |
| **`link`** | A boolean representing whether this cell should be wrapped in a link. |
| **`onClick`** | A function that is called when the cell is clicked. |
For details on how to build Custom Components themselves, see [Building Custom Components](./components#building-custom-components).
### Label
The Label Component is rendered anywhere a field needs to be represented by a label. This is typically used in the Edit View, but can also be used in the List View and elsewhere.
To easily swap in your own Label Component, use the `admin.components.Label` property in your [Field Config](../fields/overview):
```ts
import type { Field } from 'payload'
export const myField: Field = {
name: 'myField',
type: 'text',
admin: {
components: {
Label: '/path/to/MyCustomLabelComponent', // highlight-line
},
},
}
```
All Custom Label Components receive the same [Default Field Component Props](#field).
For details on how to build Custom Components themselves, see [Building Custom Components](./components#building-custom-components).
#### TypeScript
When building Custom Label Components, you can import the component types to ensure type safety in your component. There is an explicit type for the Label Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to append `LabelServerComponent` or `LabelClientComponent` to the type of field, i.e. `TextFieldLabelClientComponent`.
```tsx
import type {
TextFieldLabelServerComponent,
TextFieldLabelClientComponent,
// ...and so on for each Field Type
} from 'payload'
```
### Description
Alternatively to the [Description Property](#the-description-property), you can also use a [Custom Component](./components) as the Field Description. This can be useful when you need to provide more complex feedback to the user, such as rendering dynamic field values or other interactive elements.
To easily add a Description Component to a field, use the `admin.components.Description` property in your [Field Config](../fields/overview):
```ts
import type { SanitizedCollectionConfig } from 'payload'
export const MyCollectionConfig: SanitizedCollectionConfig = {
// ...
fields: [
// ...
{
name: 'myField',
type: 'text',
admin: {
components: {
Description: '/path/to/MyCustomDescriptionComponent', // highlight-line
}
}
}
]
}
```
All Custom Description Components receive the same [Default Field Component Props](#field).
For details on how to build a Custom Components themselves, see [Building Custom Components](./components#building-custom-components).
#### TypeScript
When building Custom Description Components, you can import the component props to ensure type safety in your component. There is an explicit type for the Description Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to append `DescriptionServerComponent` or `DescriptionClientComponent` to the type of field, i.e. `TextFieldDescriptionClientComponent`.
```tsx
import type {
TextFieldDescriptionServerComponent,
TextFieldDescriptionClientComponent,
// And so on for each Field Type
} from 'payload'
```
### Error
The Error Component is rendered when a field fails validation. It is typically displayed beneath the field input in a visually-compelling style.
To easily swap in your own Error Component, use the `admin.components.Error` property in your [Field Config](../fields/overview):
```ts
import type { Field } from 'payload'
export const myField: Field = {
name: 'myField',
type: 'text',
admin: {
components: {
Error: '/path/to/MyCustomErrorComponent', // highlight-line
},
},
}
```
All Error Components receive the [Default Field Component Props](#field).
For details on how to build Custom Components themselves, see [Building Custom Components](./components#building-custom-components).
#### TypeScript
When building Custom Error Components, you can import the component types to ensure type safety in your component. There is an explicit type for the Error Component, one for every [Field Type](../fields/overview) and server/client environment. The convention is to append `ErrorServerComponent` or `ErrorClientComponent` to the type of field, i.e. `TextFieldErrorClientComponent`.
```tsx
import type {
TextFieldErrorServerComponent,
TextFieldErrorClientComponent,
// And so on for each Field Type
} from 'payload'
```
### afterInput and beforeInput
With these properties you can add multiple components _before_ and _after_ the input element, as their name suggests. This is useful when you need to render additional elements alongside the field without replacing the entire field component.
To add components before and after the input element, use the `admin.components.beforeInput` and `admin.components.afterInput` properties in your [Field Config](../fields/overview):
```ts
import type { SanitizedCollectionConfig } from 'payload'
export const MyCollectionConfig: SanitizedCollectionConfig = {
// ...
fields: [
// ...
{
name: 'myField',
type: 'text',
admin: {
components: {
// highlight-start
beforeInput: ['/path/to/MyCustomComponent'],
afterInput: ['/path/to/MyOtherCustomComponent'],
// highlight-end
}
}
}
]
}
```
All `afterInput` and `beforeInput` Components receive the same [Default Field Component Props](#field).
For details on how to build Custom Components, see [Building Custom Components](./components#building-custom-components).

View File

@@ -29,13 +29,13 @@ The following options are available:
| ------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **`group`** | Text used as a label for grouping Collection and Global links together in the navigation. |
| **`hidden`** | Set to true or a function, called with the current user, returning true to exclude this Global from navigation and admin routing. |
| **`components`** | Swap in your own React components to be used within this Global. [More details](#components). |
| **`components`** | Swap in your own React components to be used within this Global. [More details](#custom-components). |
| **`preview`** | Function to generate a preview URL within the Admin Panel for this Global that can point to your app. [More details](#preview). |
| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
| **`hideAPIURL`** | Hides the "API URL" meta field while editing documents within this collection. |
| **`meta`** | Page metadata overrides to apply to this Global within the Admin Panel. [More details](./metadata). |
### Components
### Custom Components
Globals can set their own [Custom Components](./components) which only apply to [Global](../configuration/globals)-specific UI within the [Admin Panel](./overview). This includes elements such as the Save Button, or entire layouts such as the Edit View.

View File

@@ -44,7 +44,7 @@ The `useField` hook accepts the following arguments:
| Property | Description |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `path` | If you do not provide a `path` or a `name`, this hook will look for one using the [`useFieldProps`](#usefieldprops) hook. |
| `path` | If you do not provide a `path`, `name` will be used instead. This is the path to the field in the form data. |
| `validate` | A validation function executed client-side _before_ submitting the form to the server. Different than [Field-level Validation](../fields/overview#validation) which runs strictly on the server. |
| `disableFormData` | If `true`, the field will not be included in the form data when the form is submitted. |
| `hasRows` | If `true`, the field will be treated as a field with rows. This is useful for fields like `array` and `blocks`. |
@@ -72,32 +72,6 @@ type FieldType<T> = {
}
```
## useFieldProps
[Custom Field Components](./fields#the-field-component) can be rendered on the server. When using a server component as a custom field component, you can access dynamic props from within any client component rendered by your custom server component. This is done using the `useFieldProps` hook. This is important because some fields can be dynamic, such as when nested in an [`array`](../fields/array) or [`blocks`](../fields/block) field. For example, items can be added, re-ordered, or deleted on-the-fly.
You can use the `useFieldProps` hooks to access dynamic props like `path`:
```tsx
'use client'
import { useFieldProps } from '@payloadcms/ui'
const CustomTextField: React.FC = () => {
const { path } = useFieldProps() // highlight-line
return (
<div>
{path}
</div>
)
}
```
<Banner type="success">
<strong>Tip:</strong>
The [`useField`](#usefield) hook calls the `useFieldProps` hook internally, so you don't need to use both in the same component unless explicitly needed.
</Banner>
## useFormFields
There are times when a custom field component needs to have access to data from other fields, and you have a few options to do so. The `useFormFields` hook is a powerful and highly performant way to retrieve a form's field state, as well as to retrieve the `dispatchFields` method, which can be helpful for setting other fields' form states from anywhere within a form.
@@ -900,27 +874,6 @@ const MyComponent: React.FC = () => {
}
```
## useTableCell
Similar to [`useFieldProps`](#usefieldprops), all [Custom Cell Components](./fields#the-cell-component) are rendered on the server, and as such, only have access to static props at render time. But, some props need to be dynamic, such as the field value itself.
For this reason, dynamic props like `cellData` are managed in their own React context, which can be accessed using the `useTableCell` hook.
```tsx
'use client'
import { useTableCell } from '@payloadcms/ui'
const MyComponent: React.FC = () => {
const { cellData } = useTableCell() // highlight-line
return (
<div>
{cellData}
</div>
)
}
```
## useDocumentEvents
The `useDocumentEvents` hook provides a way of subscribing to cross-document events, such as updates made to nested documents within a drawer. This hook will report document events that are outside the scope of the document currently being edited. This hook provides the following:

View File

@@ -195,7 +195,7 @@ app/
<Banner type="warning">
<strong>Note:</strong>
If you set Root-level Routes _before_ auto-generating the Admin Panel, your [Project Structure](#project-structure) will already be set up correctly.
If you set Root-level Routes _before_ auto-generating the Admin Panel via `create-payload-app`, your [Project Structure](#project-structure) will already be set up correctly.
</Banner>
### Admin-level Routes

View File

@@ -15,7 +15,7 @@ There are four types of views within the Admin Panel:
- [Global Views](#global-views)
- [Document Views](#document-views)
To swap in your own Custom Views, consult the list of available components. Determine the scope that corresponds to what you are trying to accomplish, then [author your React component(s)](#building-custom-views) accordingly.
To swap in your own Custom View, first consult the list of available components, determine the scope that corresponds to what you are trying to accomplish, then [author your React component(s)](#building-custom-views) accordingly.
## Root Views

View File

@@ -65,7 +65,7 @@ export default buildConfig({
},
],
defaultLocale: 'en', // required
fallback: true,
fallback: true, // defaults to true
},
})
```
@@ -81,7 +81,7 @@ The following options are available:
| -------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **`locales`** | Array of all the languages that you would like to support. [More details](#locales) |
| **`defaultLocale`** | Required string that matches one of the locale codes from the array provided. By default, if no locale is specified, documents will be returned in this locale. |
| **`fallback`** | Boolean enabling "fallback" locale functionality. If a document is requested in a locale, but a field does not have a localized value corresponding to the requested locale, then if this property is enabled, the document will automatically fall back to the fallback locale value. If this property is not enabled, the value will not be populated. |
| **`fallback`** | Boolean enabling "fallback" locale functionality. If a document is requested in a locale, but a field does not have a localized value corresponding to the requested locale, then if this property is enabled, the document will automatically fall back to the fallback locale value. If this property is not enabled, the value will not be populated unless a fallback is explicitly provided in the request. True by default. |
### Locales

View File

@@ -76,6 +76,7 @@ The following options are available:
| **`cors`** | Cross-origin resource sharing (CORS) is a mechanism that accept incoming requests from given domains. You can also customize the `Access-Control-Allow-Headers` header. [More details](#cors). |
| **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). |
| **`logger`** | Logger options, logger options with a destination stream, or an instantiated logger instance. [More details](https://getpino.io/#/docs/api?id=options). |
| **`loggingLevels`** | An object to override the level to use in the logger for Payload's errors. |
| **`graphQL`** | Manage GraphQL-specific functionality, including custom queries and mutations, query complexity limits, etc. [More details](../graphql/overview#graphql-options). |
| **`cookiePrefix`** | A string that will be prefixed to all cookies that Payload sets. |
| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/overview#csrf-protection). |

View File

@@ -6,7 +6,7 @@ desc: Array Fields are intended for sets of repeating fields, that you define. L
keywords: array, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Array Field is used when you need to have a set of "repeating" [Fields](./overview). It stores an array of objects containing fields that you define. These fields can be of any type, including other arrays to achieve infinitely nested structures.
The Array Field is used when you need to have a set of "repeating" [Fields](./overview). It stores an array of objects containing fields that you define. These fields can be of any type, including other arrays, to achieve infinitely nested data structures.
Arrays are useful for many different types of content from simple to complex, such as:

View File

@@ -147,7 +147,7 @@ You can control the user experience of the join field using the `admin` config p
| Option | Description |
|------------------------|----------------------------------------------------------------------------------------|
| **`allowCreate`** | Set to `false` to remove the controls for making new related documents from this field. |
| **`components.Label`** | Override the default Label of the Field Component. [More details](#the-label-component). |
| **`components.Label`** | Override the default Label of the Field Component. [More details](../admin/fields#label) |
## Join Field Data

View File

@@ -7,7 +7,7 @@ desc: The JSON field type will store any string in the Database. Learn how to us
keywords: json, jsonSchema, schema, validation, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The JSON Field saves actual JSON in the database, which differs from the Code field that saves the value as a string in the database.
The JSON Field saves raw JSON to the database and provides the [Admin Panel](../admin/overview) with a code editor styled interface. This is different from the [Code Field](./code) which saves the value as a string in the database.
<LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/json.png"

View File

@@ -93,13 +93,14 @@ Presentational Fields do not store data in the database. Instead, they are used
Here are the available Presentational Fields:
- [Collapsible](/docs/fields/collapsible) - nests fields within a collapsible component
- [Join](/docs/fields/join) - achieves two-way data binding between fields
- [Row](/docs/fields/row) - aligns fields horizontally
- [Tabs (Unnamed)](/docs/fields/tabs) - nests fields within a tabbed layout
- [UI](/docs/fields/ui) - blank field for custom UI components
<Banner type="warning">
<strong>Tip:</strong>
Don't see a Field Type that fits your needs? You can build your own using a [Custom Field Component](../admin/fields#the-field-component).
Don't see a Field Type that fits your needs? You can build your own using a [Custom Field Component](../admin/fields#field).
</Banner>
## Field Options
@@ -123,7 +124,7 @@ export const MyField: Field = {
### Field Names
All [Data Fields](#data-fields) require a `name` property. This is the key that will be used to store and retrieve the field's value in the database. This property must be unique within the Collection, Global, or nested group that it is defined in.
All [Data Fields](#data-fields) require a `name` property. This is the key that will be used to store and retrieve the field's value in the database. This property must be unique amongst this field's siblings.
To set a field's name, use the `name` property in your Field Config:
@@ -205,7 +206,7 @@ export const MyField: Field = {
}
```
Default values can be defined as a static value or a function that returns a value. When a `defaultValue` is defined statically, Payload's DB adapters will apply it to the database schema or models.
Default values can be defined as a static value or a function that returns a value. When a `defaultValue` is defined statically, Payload's [Database Adapters](../database/overview) will apply it to the database schema or models.
Functions can be written to make use of the following argument properties:
@@ -264,7 +265,7 @@ The following arguments are provided to the `validate` function:
#### Validation Context
The `ctx` argument contains full document data, sibling field data, the current operation, and other useful information such as currently authenticated in user:
The `ctx` argument contains full document data, sibling field data, the current operation, and other useful information such as currently authenticated user:
```ts
import type { Field } from 'payload'
@@ -358,7 +359,7 @@ For full details on Admin Options, see the [Field Admin Options](../admin/fields
All [Collections](../configuration/collections) automatically generate their own ID field. If needed, you can override this behavior by providing an explicit ID field to your config. This field should either be required or have a hook to generate the ID dynamically.
To define a custom ID field, add a new field with the `name` property set to `id`:
To define a custom ID field, add a top-level field with the `name` property set to `id`:
```ts
import type { CollectionConfig } from 'payload'

View File

@@ -204,4 +204,4 @@ If you are looking to create a dynamic select field, the following tutorial will
drawerTitle="How to Create a Custom Select Field: A Step-by-Step Guide"
/>
If you want to learn more about custom components check out the [Admin > Custom Component](/docs/admin/components#field-component) docs.
If you want to learn more about custom components check out the [Admin > Custom Component](/docs/admin/components#field) docs.

View File

@@ -32,8 +32,8 @@ export const MyUIField: Field = {
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **`name`** \* | A unique identifier for this field. |
| **`label`** | Human-readable label for this UI field. |
| **`admin.components.Field`** \* | React component to be rendered for this field within the Edit View. [More](../admin/components/#field-component) |
| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More](../admin/components/#field-component) |
| **`admin.components.Field`** \* | React component to be rendered for this field within the Edit View. [More](../admin/components/#field) |
| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More](../admin/components/#field) |
| **`admin.disableListColumn`** | Set `disableListColumn` to `true` to prevent the UI field from appearing in the list view column selector. |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |

View File

@@ -35,6 +35,10 @@ Adding Payload to an existing Next.js app is super straightforward. You can eith
If you don't have a Next.js app already, but you still want to start a project from a blank Next.js app, you can create a new Next.js app using `npx create-next-app` - and then just follow the steps below to install Payload.
<Banner type="info">
<strong>Note:</strong> Next.js version 15 or higher is required for Payload.
</Banner>
#### 1. Install the relevant packages
First, you'll want to add the required Payload packages to your project and can do so by running the command below:

46
docs/jobs-queue/jobs.mdx Normal file
View File

@@ -0,0 +1,46 @@
---
title: Jobs
label: Jobs
order: 40
desc: A Job is a set of work that is offloaded from your APIs and will be processed at a later date.
keywords: jobs queue, application framework, typescript, node, react, nextjs
---
Now that we have covered Tasks and Workflows, we can tie them together with a concept called a Job.
<Banner type="default">
Whereas you define Workflows and Tasks, which control your business logic, a <strong>Job</strong> is an individual instance of either a Task or a Workflow which contains many tasks.
</Banner>
For example, let's say we have a Workflow or Task that describes the logic to sync information from Payload to a third-party system. This is how you'd declare how to sync that info, but it wouldn't do anything on its own. In order to run that task or workflow, you'd create a Job that references the corresponding Task or Workflow.
Jobs are stored in the Payload database in the `payload-jobs` collection, and you can decide to keep a running list of all jobs, or configure Payload to delete the job when it has been successfully executed.
#### Queuing a new job
In order to queue a job, you can use the `payload.jobs.queue` function.
Here's how you'd queue a new Job, which will run a `createPostAndUpdate` workflow:
```ts
const createdJob = await payload.jobs.queue({
// Pass the name of the workflow
workflow: 'createPostAndUpdate',
// The input type will be automatically typed
// according to the input you've defined for this workflow
input: {
title: 'my title',
},
})
```
In addition to being able to queue new Jobs based on Workflows, you can also queue a job for a single Task:
```ts
const createdJob = await payload.jobs.queue({
task: 'createPost',
input: {
title: 'my title',
},
})
```

View File

@@ -6,458 +6,65 @@ desc: Payload provides all you need to run job queues, which are helpful to offl
keywords: jobs queue, application framework, typescript, node, react, nextjs
---
Payload's Jobs Queue gives you a simple, yet powerful way to offload large or future tasks to separate compute resources.
Payload's Jobs Queue gives you a simple, yet powerful way to offload large or future tasks to separate compute resources which is a very powerful feature of many application frameworks.
For example, when building applications with Payload, you might run into a case where you need to perform some complex logic in a Payload [Hook](/docs/hooks/overview) but you don't want that hook to "block" or slow down the response returned from the Payload API.
### Example use cases
Instead of running long or expensive logic in a Hook, you can instead create a Job and add it to a Queue. It can then be picked up by a separate worker which periodically checks the queue for new jobs, and then executes each job accordingly. This way, your Payload API responses can remain as fast as possible, and you can still perform logic as necessary without blocking or affecting your users' experience.
**Non-blocking workloads**
Jobs are also handy for delegating certain actions to take place in the future, such as scheduling a post to be published at a later date. In this example, you could create a Job that will automatically publish a post at a certain time.
You might need to perform some complex, slow-running logic in a Payload [Hook](/docs/hooks/overview) but you don't want that hook to "block" or slow down the response returned from the Payload API. Instead of running this logic directly in a hook, which would block your API response from returning until the expensive work is completed, you can queue a new Job and let it run at a later date.
#### How it works
Examples:
There are a few concepts that you should become familiarized with before using Payload's Jobs Queue - [Tasks](#tasks), [Workflows](#workflows), [Jobs](#jobs), and finally [Queues](#queues).
- Create vector embeddings from your documents, and keep them in sync as your documents change
- Send data to a third-party API on document change
- Trigger emails based on customer actions
## Tasks
**Scheduled actions**
<Banner type="default">
A <strong>"Task"</strong> is a function definition that performs business logic and whose input and output are both strongly typed.
</Banner>
If you need to schedule an action to be run or processed at a certain date in the future, you can queue a job with the `waitUntil` property set. This will make it so the job is not "picked up" until that `waitUntil` date has passed.
You can register Tasks on the Payload config, and then create Jobs or Workflows that use them. Think of Tasks like tidy, isolated "functions that do one specific thing".
Examples:
Payload Tasks can be configured to automatically retried if they fail, which makes them valuable for "durable" workflows like AI applications where LLMs can return non-deterministic results, and might need to be retried.
- Process scheduled posts, where the scheduled date is at a time set in the future
- Unpublish posts at a given time
- Send a reminder email to a customer after X days of signing up for a trial
Tasks can either be defined within the `jobs.tasks` array in your payload config, or they can be defined inline within a workflow.
**Periodic sync or similar scheduled action**
### Defining tasks in the config
Some applications may need to perform a regularly scheduled operation of some type. Jobs are perfect for this because you can execute their logic using `cron`, scheduled nightly, every twelve hours, or some similar time period.
Simply add a task to the `jobs.tasks` array in your Payload config. A task consists of the following fields:
Examples:
| Option | Description |
| --------------------------- | -------------------------------------------------------------------------------- |
| `slug` | Define a slug-based name for this job. This slug needs to be unique among both tasks and workflows.|
| `handler` | The function that should be responsible for running the job. You can either pass a string-based path to the job function file, or the job function itself. If you are using large dependencies within your job, you might prefer to pass the string path because that will avoid bundling large dependencies in your Next.js app. |
| `inputSchema` | Define the input field schema - payload will generate a type for this schema. |
| `interfaceName` | You can use interfaceName to change the name of the interface that is generated for this task. By default, this is "Task" + the capitalized task slug. |
| `outputSchema` | Define the output field schema - payload will generate a type for this schema. |
| `label` | Define a human-friendly label for this task. |
| `onFail` | Function to be executed if the task fails. |
| `onSuccess` | Function to be executed if the task succeeds. |
| `retries` | Specify the number of times that this step should be retried if it fails. |
- You'd like to send emails to all customers on a regular, scheduled basis
- Periodically trigger a rebuild of your frontend at night
- Sync resources to or from a third-party API during non-peak times
The logic for the Task is defined in the `handler` - which can be defined as a function, or a path to a function. The `handler` will run once a worker picks picks up a Job that includes this task.
**Offloading complex operations**
It should return an object with an `output` key, which should contain the output of the task as you've defined.
You may run into the need to perform computationally expensive functions which might slow down your main Payload API server(s). The Jobs Queue allows you to offload these tasks a separate compute resource rather than slowing down the server(s) that run your Payload APIs. With Payload Task definitions, you can even keep large dependencies out of your main Next.js bundle by dynamically importing them only when they are used. This keeps your Next.js + Payload compilation fast and ensures large dependencies do not get bundled into your Payload production build.
Example:
Examples:
```ts
export default buildConfig({
// ...
jobs: {
tasks: [
{
// Configure this task to automatically retry
// up to two times
retries: 2,
- You need to create (and then keep in sync) vector embeddings of your documents as they change, but you use an open source model to generate embeddings
- You have a PDF generator that needs to dynamically build and send PDF versions of documents to customers
- You need to use a headless browser to perform some type of logic
- You need to perform a series of actions, each of which depends on a prior action and should be run in as "durable" of a fashion as possible
// This is a unique identifier for the task
### How it works
slug: 'createPost',
There are a few concepts that you should become familiarized with before using Payload's Jobs Queue. We recommend learning what each of these does in order to fully understand how to leverage the power of Payload's Jobs Queue.
// These are the arguments that your Task will accept
inputSchema: [
{
name: 'title',
type: 'text',
required: true,
},
],
1. [Tasks](/docs/beta/jobs-queue/tasks)
1. [Workflows](/docs/beta/jobs-queue/workflows)
1. [Jobs](/docs/beta/jobs-queue/jobs)
1. [Queues](/docs/beta/jobs-queue/queues)
// These are the properties that the function should output
outputSchema: [
{
name: 'postID',
type: 'text',
required: true,
},
],
All of these pieces work together in order to allow you to offload long-running, expensive, or future scheduled work from your main APIs.
// This is the function that is run when the task is invoked
handler: async ({ input, job, req }) => {
const newPost = await req.payload.create({
collection: 'post',
req,
data: {
title: input.title,
},
})
return {
output: {
postID: newPost.id,
},
}
},
} as TaskConfig<'createPost'>,
]
}
})
```
Here's a quick overview:
In addition to defining handlers as functions directly provided to your Payload config, you can also pass an _absolute path_ to where the handler is defined. If your task has large dependencies, and you are planning on executing your jobs in a separate process that has access to the filesystem, this could be a handy way to make sure that your Payload + Next.js app remains quick to compile and has minimal dependencies.
In general, this is an advanced use case. Here's how this would look:
`payload.config.ts:`
```ts
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
jobs: {
tasks: [
{
// ...
// The #createPostHandler is a named export within the `createPost.ts` file
handler: path.resolve(dirname, 'src/tasks/createPost.ts') + '#createPostHandler',
}
]
}
})
```
Then, the `createPost` file itself:
`src/tasks/createPost.ts:`
```ts
import type { TaskHandler } from 'payload'
export const createPostHandler: TaskHandler<'createPost'> = async ({ input, job, req }) => {
const newPost = await req.payload.create({
collection: 'post',
req,
data: {
title: input.title,
},
})
return {
output: {
postID: newPost.id,
},
}
}
```
## Workflows
<Banner type="default">
A <strong>"Workflow"</strong> is an optional way to <em>combine multiple tasks together</em> in a way that can be gracefully retried from the point of failure.
</Banner>
They're most helpful when you have multiple tasks in a row, and you want to configure each task to be able to be retried if they fail.
If a task within a workflow fails, the Workflow will automatically "pick back up" on the task where it failed and **not re-execute any prior tasks that have already been executed**.
#### Defining a workflow
The most important aspect of a Workflow is the `handler`, where you can declare when and how the tasks should run by simply calling the `runTask` function. If any task within the workflow, fails, the entire `handler` function will re-run.
However, importantly, tasks that have successfully been completed will simply re-return the cached and saved output without running again. The Workflow will pick back up where it failed and only task from the failure point onward will be re-executed.
To define a JS-based workflow, simply add a workflow to the `jobs.wokflows` array in your Payload config. A workflow consists of the following fields:
| Option | Description |
| --------------------------- | -------------------------------------------------------------------------------- |
| `slug` | Define a slug-based name for this workflow. This slug needs to be unique among both tasks and workflows.|
| `handler` | The function that should be responsible for running the workflow. You can either pass a string-based path to the workflow function file, or workflow job function itself. If you are using large dependencies within your workflow, you might prefer to pass the string path because that will avoid bundling large dependencies in your Next.js app. |
| `inputSchema` | Define the input field schema - payload will generate a type for this schema. |
| `interfaceName` | You can use interfaceName to change the name of the interface that is generated for this workflow. By default, this is "Workflow" + the capitalized workflow slug. |
| `label` | Define a human-friendly label for this workflow. |
| `queue` | Optionally, define the queue name that this workflow should be tied to. Defaults to "default". |
Example:
```ts
export default buildConfig({
// ...
jobs: {
tasks: [
// ...
]
workflows: [
{
slug: 'createPostAndUpdate',
// The arguments that the workflow will accept
inputSchema: [
{
name: 'title',
type: 'text',
required: true,
},
],
// The handler that defines the "control flow" of the workflow
// Notice how it calls `runTask` to execute tasks
handler: async ({ job, runTask }) => {
// This workflow first runs a task called `createPost`
const output = await runTask({
task: 'createPost',
// You need to define a unique ID for this task invocation
// that will always be the same if this workflow fails
// and is re-executed in the future
id: '1',
input: {
title: job.input.title,
},
})
// Once the prior task completes, it will run a task
// called `updatePost`
await runTask({
task: 'updatePost',
id: '2',
input: {
post: job.taskStatus.createPost['1'].output.postID, // or output.postID
title: job.input.title + '2',
},
})
},
} as WorkflowConfig<'updatePost'>
]
}
})
```
#### Running tasks inline
In the above example, our workflow was executing tasks that we already had defined in our Payload config. But, you can also run tasks without predefining them.
To do this, you can use the `runTaskInline` function.
The drawbacks of this approach are that tasks cannot be re-used across workflows as easily, and the **task data stored in the job** will not be typed. In the following example, the inline task data will be stored on the job under `job.taskStatus.inline['2']` but completely untyped, as types for dynamic tasks like these cannot be generated beforehand.
Example:
```ts
export default buildConfig({
// ...
jobs: {
tasks: [
// ...
]
workflows: [
{
slug: 'createPostAndUpdate',
inputSchema: [
{
name: 'title',
type: 'text',
required: true,
},
],
handler: async ({ job, runTask }) => {
// Here, we run a predefined task.
// The `createPost` handler arguments and return type
// are both strongly typed
const output = await runTask({
task: 'createPost',
id: '1',
input: {
title: job.input.title,
},
})
// Here, this task is not defined in the Payload config
// and is "inline". Its output will be stored on the Job in the database
// however its arguments will be untyped.
const { newPost } = await runTaskInline({
task: async ({ req }) => {
const newPost = await req.payload.update({
collection: 'post',
id: '2',
req,
retries: 3,
data: {
title: 'updated!',
},
})
return {
output: {
newPost
},
}
},
id: '2',
})
},
} as WorkflowConfig<'updatePost'>
]
}
})
```
## Jobs
Now that we have covered Tasks and Workflows, we can tie them together with a concept called a Job.
<Banner type="default">
Whereas you define Workflows and Tasks, which control your business logic, a <strong>Job</strong> is an individual instance of either a Task or a Workflow which contains many tasks.
</Banner>
For example, let's say we have a Workflow or Task that describes the logic to sync information from Payload to a third-party system. This is how you'd declare how to sync that info, but it wouldn't do anything on its own. In order to run that task or workflow, you'd create a Job that references the corresponding Task or Workflow.
Jobs are stored in the Payload database in the `payload-jobs` collection, and you can decide to keep a running list of all jobs, or configure Payload to delete the job when it has been successfully executed.
#### Queuing a new job
In order to queue a job, you can use the `payload.jobs.queue` function.
Here's how you'd queue a new Job, which will run a `createPostAndUpdate` workflow:
```ts
const createdJob = await payload.jobs.queue({
// Pass the name of the workflow
workflow: 'createPostAndUpdate',
// The input type will be automatically typed
// according to the input you've defined for this workflow
input: {
title: 'my title',
},
})
```
In addition to being able to queue new Jobs based on Workflows, you can also queue a job for a single Task:
```ts
const createdJob = await payload.jobs.queue({
task: 'createPost',
input: {
title: 'my title',
},
})
```
## Queues
Now let's talk about how to _run these jobs_. Right now, all we've covered is how to queue up jobs to run, but so far, we aren't actually running any jobs. This is the final piece of the puzzle.
<Banner type="default">
A <strong>Queue</strong> is a list of jobs that should be executed in order of when they were added.
</Banner>
When you go to run jobs, Payload will query for any jobs that are added to the queue and then run them. By default, all queued jobs are added to the `default` queue.
**But, imagine if you wanted to have some jobs that run nightly, and other jobs which should run every five minutes.**
By specifying the `queue` name when you queue a new job using `payload.jobs.queue()`, you can queue certain jobs with `queue: 'nightly'`, and other jobs can be left as the default queue.
Then, you could configure two different runner strategies:
1. A `cron` that runs nightly, querying for jobs added to the `nightly` queue
2. Another that runs any jobs that were added to the `default` queue every ~5 minutes or so
## Executing jobs
As mentioned above, you can queue jobs, but the jobs won't run unless a worker picks up your jobs and runs them. This can be done in two ways:
#### Endpoint
You can execute jobs by making a fetch request to the `/api/payload-jobs/run` endpoint:
```ts
// Here, we're saying we want to run only 100 jobs for this invocation
// and we want to pull jobs from the `nightly` queue:
await fetch('/api/payload-jobs/run?limit=100&queue=nightly', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
```
This endpoint is automatically mounted for you and is helpful in conjunction with serverless platforms like Vercel, where you might want to use Vercel Cron to invoke a serverless function that executes your jobs.
**Vercel Cron Example**
If you're deploying on Vercel, you can add a `vercel.json` file in the root of your project that configures Vercel Cron to invoke the `run` endpoint on a cron schedule.
Here's an example of what this file will look like:
```json
{
"crons": [
{
"path": "/api/payload-jobs/run",
"schedule": "*/5 * * * *"
}
]
}
```
The configuration above schedules the endpoint `/api/payload-jobs/run` to be invoked every 5 minutes.
The last step will be to secure your `run` endpoint so that only the proper users can invoke the runner.
To do this, you can set an environment variable on your Vercel project called `CRON_SECRET`, which should be a random string—ideally 16 characters or longer.
Then, you can modify the `access` function for running jobs by ensuring that only Vercel can invoke your runner.
```ts
export default buildConfig({
// Other configurations...
jobs: {
access: {
run: ({ req }: { req: PayloadRequest }): boolean => {
// Allow logged in users to execute this endpoint (default)
if (req.user) return true
// If there is no logged in user, then check
// for the Vercel Cron secret to be present as an
// Authorization header:
const authHeader = req.headers.get('authorization');
return authHeader === `Bearer ${process.env.CRON_SECRET}`;
},
},
// Other job configurations...
}
})
```
This works because Vercel automatically makes the `CRON_SECRET` environment variable available to the endpoint as the `Authorization` header when triggered by the Vercel Cron, ensuring that the jobs can be run securely.
After the project is deployed to Vercel, the Vercel Cron job will automatically trigger the `/api/payload-jobs/run` endpoint in the specified schedule, running the queued jobs in the background.
#### Local API
If you want to process jobs programmatically from your server-side code, you can use the Local API:
```ts
const results = await payload.jobs.run()
// You can customize the queue name and limit by passing them as arguments:
await payload.jobs.run({ queue: 'nightly', limit: 100 })
```
#### Bin script
Finally, you can process jobs via the bin script that comes with Payload out of the box.
```sh
npx payload jobs:run --queue default --limit 10
```
In addition, the bin script allows you to pass a `--cron` flag to the `jobs:run` command to run the jobs on a scheduled, cron basis:
```sh
npx payload jobs:run --cron "*/5 * * * *"
```
- A Task is a specific function that performs business logic
- Workflows are groupings of specific tasks which should be run in-order, and can be retried from a specific point of failure
- A Job is an instance of a single task or workflow which will be executed
- A Queue is a way to segment your jobs into different "groups" - for example, some to run nightly, and others to run every 10 minutes

120
docs/jobs-queue/queues.mdx Normal file
View File

@@ -0,0 +1,120 @@
---
title: Queues
label: Queues
order: 50
desc: A Queue is a specific group of jobs which can be executed in the order that they were added.
keywords: jobs queue, application framework, typescript, node, react, nextjs
---
Queues are the final aspect of Payload's Jobs Queue and deal with how to _run your jobs_. Up to this point, all we've covered is how to queue up jobs to run, but so far, we aren't actually running any jobs.
<Banner type="default">
A <strong>Queue</strong> is a grouping of jobs that should be executed in order of when they were added.
</Banner>
When you go to run jobs, Payload will query for any jobs that are added to the queue and then run them. By default, all queued jobs are added to the `default` queue.
**But, imagine if you wanted to have some jobs that run nightly, and other jobs which should run every five minutes.**
By specifying the `queue` name when you queue a new job using `payload.jobs.queue()`, you can queue certain jobs with `queue: 'nightly'`, and other jobs can be left as the default queue.
Then, you could configure two different runner strategies:
1. A `cron` that runs nightly, querying for jobs added to the `nightly` queue
2. Another that runs any jobs that were added to the `default` queue every ~5 minutes or so
## Executing jobs
As mentioned above, you can queue jobs, but the jobs won't run unless a worker picks up your jobs and runs them. This can be done in two ways:
#### Endpoint
You can execute jobs by making a fetch request to the `/api/payload-jobs/run` endpoint:
```ts
// Here, we're saying we want to run only 100 jobs for this invocation
// and we want to pull jobs from the `nightly` queue:
await fetch('/api/payload-jobs/run?limit=100&queue=nightly', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
```
This endpoint is automatically mounted for you and is helpful in conjunction with serverless platforms like Vercel, where you might want to use Vercel Cron to invoke a serverless function that executes your jobs.
**Vercel Cron Example**
If you're deploying on Vercel, you can add a `vercel.json` file in the root of your project that configures Vercel Cron to invoke the `run` endpoint on a cron schedule.
Here's an example of what this file will look like:
```json
{
"crons": [
{
"path": "/api/payload-jobs/run",
"schedule": "*/5 * * * *"
}
]
}
```
The configuration above schedules the endpoint `/api/payload-jobs/run` to be invoked every 5 minutes.
The last step will be to secure your `run` endpoint so that only the proper users can invoke the runner.
To do this, you can set an environment variable on your Vercel project called `CRON_SECRET`, which should be a random string—ideally 16 characters or longer.
Then, you can modify the `access` function for running jobs by ensuring that only Vercel can invoke your runner.
```ts
export default buildConfig({
// Other configurations...
jobs: {
access: {
run: ({ req }: { req: PayloadRequest }): boolean => {
// Allow logged in users to execute this endpoint (default)
if (req.user) return true
// If there is no logged in user, then check
// for the Vercel Cron secret to be present as an
// Authorization header:
const authHeader = req.headers.get('authorization');
return authHeader === `Bearer ${process.env.CRON_SECRET}`;
},
},
// Other job configurations...
}
})
```
This works because Vercel automatically makes the `CRON_SECRET` environment variable available to the endpoint as the `Authorization` header when triggered by the Vercel Cron, ensuring that the jobs can be run securely.
After the project is deployed to Vercel, the Vercel Cron job will automatically trigger the `/api/payload-jobs/run` endpoint in the specified schedule, running the queued jobs in the background.
#### Local API
If you want to process jobs programmatically from your server-side code, you can use the Local API:
```ts
const results = await payload.jobs.run()
// You can customize the queue name and limit by passing them as arguments:
await payload.jobs.run({ queue: 'nightly', limit: 100 })
```
#### Bin script
Finally, you can process jobs via the bin script that comes with Payload out of the box.
```sh
npx payload jobs:run --queue default --limit 10
```
In addition, the bin script allows you to pass a `--cron` flag to the `jobs:run` command to run the jobs on a scheduled, cron basis:
```sh
npx payload jobs:run --cron "*/5 * * * *"
```

141
docs/jobs-queue/tasks.mdx Normal file
View File

@@ -0,0 +1,141 @@
---
title: Tasks
label: Tasks
order: 20
desc: A Task is a distinct function declaration that can be run within Payload's Jobs Queue.
keywords: jobs queue, application framework, typescript, node, react, nextjs
---
<Banner type="default">
A <strong>"Task"</strong> is a function definition that performs business logic and whose input and output are both strongly typed.
</Banner>
You can register Tasks on the Payload config, and then create [Jobs](/docs/beta/jobs-queue/jobs) or [Workflows](/docs/beta/jobs-queue/workflows) that use them. Think of Tasks like tidy, isolated "functions that do one specific thing".
Payload Tasks can be configured to automatically retried if they fail, which makes them valuable for "durable" workflows like AI applications where LLMs can return non-deterministic results, and might need to be retried.
Tasks can either be defined within the `jobs.tasks` array in your payload config, or they can be defined inline within a workflow.
### Defining tasks in the config
Simply add a task to the `jobs.tasks` array in your Payload config. A task consists of the following fields:
| Option | Description |
| --------------------------- | -------------------------------------------------------------------------------- |
| `slug` | Define a slug-based name for this job. This slug needs to be unique among both tasks and workflows.|
| `handler` | The function that should be responsible for running the job. You can either pass a string-based path to the job function file, or the job function itself. If you are using large dependencies within your job, you might prefer to pass the string path because that will avoid bundling large dependencies in your Next.js app. |
| `inputSchema` | Define the input field schema - payload will generate a type for this schema. |
| `interfaceName` | You can use interfaceName to change the name of the interface that is generated for this task. By default, this is "Task" + the capitalized task slug. |
| `outputSchema` | Define the output field schema - payload will generate a type for this schema. |
| `label` | Define a human-friendly label for this task. |
| `onFail` | Function to be executed if the task fails. |
| `onSuccess` | Function to be executed if the task succeeds. |
| `retries` | Specify the number of times that this step should be retried if it fails. |
The logic for the Task is defined in the `handler` - which can be defined as a function, or a path to a function. The `handler` will run once a worker picks picks up a Job that includes this task.
It should return an object with an `output` key, which should contain the output of the task as you've defined.
Example:
```ts
export default buildConfig({
// ...
jobs: {
tasks: [
{
// Configure this task to automatically retry
// up to two times
retries: 2,
// This is a unique identifier for the task
slug: 'createPost',
// These are the arguments that your Task will accept
inputSchema: [
{
name: 'title',
type: 'text',
required: true,
},
],
// These are the properties that the function should output
outputSchema: [
{
name: 'postID',
type: 'text',
required: true,
},
],
// This is the function that is run when the task is invoked
handler: async ({ input, job, req }) => {
const newPost = await req.payload.create({
collection: 'post',
req,
data: {
title: input.title,
},
})
return {
output: {
postID: newPost.id,
},
}
},
} as TaskConfig<'createPost'>,
]
}
})
```
In addition to defining handlers as functions directly provided to your Payload config, you can also pass an _absolute path_ to where the handler is defined. If your task has large dependencies, and you are planning on executing your jobs in a separate process that has access to the filesystem, this could be a handy way to make sure that your Payload + Next.js app remains quick to compile and has minimal dependencies.
In general, this is an advanced use case. Here's how this would look:
`payload.config.ts:`
```ts
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
jobs: {
tasks: [
{
// ...
// The #createPostHandler is a named export within the `createPost.ts` file
handler: path.resolve(dirname, 'src/tasks/createPost.ts') + '#createPostHandler',
}
]
}
})
```
Then, the `createPost` file itself:
`src/tasks/createPost.ts:`
```ts
import type { TaskHandler } from 'payload'
export const createPostHandler: TaskHandler<'createPost'> = async ({ input, job, req }) => {
const newPost = await req.payload.create({
collection: 'post',
req,
data: {
title: input.title,
},
})
return {
output: {
postID: newPost.id,
},
}
}
```

View File

@@ -0,0 +1,150 @@
---
title: Workflows
label: Workflows
order: 30
desc: A Task is a distinct function declaration that can be run within Payload's Jobs Queue.
keywords: jobs queue, application framework, typescript, node, react, nextjs
---
<Banner type="default">
A <strong>"Workflow"</strong> is an optional way to <em>combine multiple tasks together</em> in a way that can be gracefully retried from the point of failure.
</Banner>
They're most helpful when you have multiple tasks in a row, and you want to configure each task to be able to be retried if they fail.
If a task within a workflow fails, the Workflow will automatically "pick back up" on the task where it failed and **not re-execute any prior tasks that have already been executed**.
#### Defining a workflow
The most important aspect of a Workflow is the `handler`, where you can declare when and how the tasks should run by simply calling the `runTask` function. If any task within the workflow, fails, the entire `handler` function will re-run.
However, importantly, tasks that have successfully been completed will simply re-return the cached and saved output without running again. The Workflow will pick back up where it failed and only task from the failure point onward will be re-executed.
To define a JS-based workflow, simply add a workflow to the `jobs.wokflows` array in your Payload config. A workflow consists of the following fields:
| Option | Description |
| --------------------------- | -------------------------------------------------------------------------------- |
| `slug` | Define a slug-based name for this workflow. This slug needs to be unique among both tasks and workflows.|
| `handler` | The function that should be responsible for running the workflow. You can either pass a string-based path to the workflow function file, or workflow job function itself. If you are using large dependencies within your workflow, you might prefer to pass the string path because that will avoid bundling large dependencies in your Next.js app. |
| `inputSchema` | Define the input field schema - payload will generate a type for this schema. |
| `interfaceName` | You can use interfaceName to change the name of the interface that is generated for this workflow. By default, this is "Workflow" + the capitalized workflow slug. |
| `label` | Define a human-friendly label for this workflow. |
| `queue` | Optionally, define the queue name that this workflow should be tied to. Defaults to "default". |
Example:
```ts
export default buildConfig({
// ...
jobs: {
tasks: [
// ...
]
workflows: [
{
slug: 'createPostAndUpdate',
// The arguments that the workflow will accept
inputSchema: [
{
name: 'title',
type: 'text',
required: true,
},
],
// The handler that defines the "control flow" of the workflow
// Notice how it uses the `tasks` argument to execute your predefined tasks.
// These are strongly typed!
handler: async ({ job, tasks }) => {
// This workflow first runs a task called `createPost`.
// You need to define a unique ID for this task invocation
// that will always be the same if this workflow fails
// and is re-executed in the future. Here, we hard-code it to '1'
const output = await tasks.createPost('1', {
input: {
title: job.input.title,
},
})
// Once the prior task completes, it will run a task
// called `updatePost`
await tasks.updatePost('2', {
input: {
post: job.taskStatus.createPost['1'].output.postID, // or output.postID
title: job.input.title + '2',
},
})
},
} as WorkflowConfig<'updatePost'>
]
}
})
```
#### Running tasks inline
In the above example, our workflow was executing tasks that we already had defined in our Payload config. But, you can also run tasks without predefining them.
To do this, you can use the `inlineTask` function.
The drawbacks of this approach are that tasks cannot be re-used across workflows as easily, and the **task data stored in the job** will not be typed. In the following example, the inline task data will be stored on the job under `job.taskStatus.inline['2']` but completely untyped, as types for dynamic tasks like these cannot be generated beforehand.
Example:
```ts
export default buildConfig({
// ...
jobs: {
tasks: [
// ...
]
workflows: [
{
slug: 'createPostAndUpdate',
inputSchema: [
{
name: 'title',
type: 'text',
required: true,
},
],
handler: async ({ job, tasks, inlineTask }) => {
// Here, we run a predefined task.
// The `createPost` handler arguments and return type
// are both strongly typed
const output = await tasks.createPost('1', {
input: {
title: job.input.title,
},
})
// Here, this task is not defined in the Payload config
// and is "inline". Its output will be stored on the Job in the database
// however its arguments will be untyped.
const { newPost } = await inlineTask('2', {
task: async ({ req }) => {
const newPost = await req.payload.update({
collection: 'post',
id: '2',
req,
retries: 3,
data: {
title: 'updated!',
},
})
return {
output: {
newPost
},
}
},
})
},
} as WorkflowConfig<'updatePost'>
]
}
})
```

View File

@@ -370,7 +370,12 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
// Import editor state into your headless editor
try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately
headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true }, // This should commit the editor state immediately
)
} catch (e) {
logger.error({ err: e }, 'ERROR parsing editor state')
}
@@ -382,8 +387,6 @@ headlessEditor.getEditorState().read(() => {
})
```
The `.setEditorState()` function immediately updates your editor state. Thus, there's no need for the `discrete: true` flag when reading the state afterward.
## Lexical => Plain Text
Export content from the Lexical editor into plain text using these steps:
@@ -401,8 +404,13 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
// Import editor state into your headless editor
try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately
} catch (e) {
headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true }, // This should commit the editor state immediately
)
} catch (e) {
logger.error({ err: e }, 'ERROR parsing editor state')
}

View File

@@ -173,7 +173,6 @@ import {
useEntityVisibility,
useField,
useFieldComponents,
useFieldProps,
useForm,
useFormFields,
useFormInitializing,
@@ -199,7 +198,6 @@ import {
useSearchParams,
useSelection,
useStepNav,
useTableCell,
useTheme,
useThrottledEffect,
useTranslation,
@@ -468,10 +466,9 @@ export const ServerRenderedDescription = () => <ClientRenderedDescription />
'use client'
import React from 'react'
import type { TextFieldDescriptionClientComponent } from 'payload'
import { useFieldProps, useFormFields } from '@payloadcms/ui'
import { useFormFields } from '@payloadcms/ui'
export const ClientRenderedDescription: TextFieldDescriptionClientComponent = () ={
const { path } = useFieldProps()
export const ClientRenderedDescription: TextFieldDescriptionClientComponent = ({ path }) => {
const { value } = useFormFields(([fields]) => fields[path])
const customDescription = `Component description: ${path} - ${value}`
@@ -526,10 +523,9 @@ export const ServerRenderedCollapsibleLabel = () => <ClientCollapsibleLabel />
// file: components/ClientCollapsibleLabel.tsx
'use client'
import React from 'react'
import { useFieldProps, useFormFields } from '@payloadcms/ui'
import { useFormFields } from '@payloadcms/ui'
export const ClientCollapsibleLabel = () => {
const { path } = useFieldProps()
export const ClientCollapsibleLabel = ({ path }) => {
const { value } = useFormFields(([fields]) => fields[path])
const customLabel = `${value?.fieldInCollapsible || 'Untitled Collapsible'}`
@@ -537,63 +533,6 @@ export const ClientCollapsibleLabel = () => {
}
```
11. The `admin.components.Cell` no longer receives `rowData` or `cellData`.
If using a custom component, you must now get the data yourself via the `useTableCell` hook, i.e. `const { cellData, rowData } = useTableCell()`.
```tsx
// file: payload.config.ts
import { buildConfig } from 'payload'
import { ServerRenderedTitleCell } from './components/ServerRenderedTitleCell.tsx'
export default buildConfig({
// ...
collections: [
{
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
admin: {
components: {
Cell: ServerRenderedTitleCell
}
}
}
]
}
]
// ...
})
// file: components/ServerRenderedTitleCell.tsx
import React from 'react'
import { ClientTitleCell } from './ClientTitleCell.tsx'
export const ServerRenderedTitleCell = () => <ClientTitleCell />
// file: components/ClientTitleCell.tsx
'use client'
import React from 'react'
import { useTableCell } from '@payloadcms/ui'
export const ClientTitleCell: CellComponent = () ={
const { cellData, rowData } = useTableCell()
const customCellText = `Component cell: ${cellData}`
return (
<div>
{customCellText}
</div>
)
}
```
12. `admin.components.RowLabel` no longer accepts a function, instead use a custom component and make use of the `useRowLabel` hook:
```tsx

View File

@@ -151,7 +151,7 @@ With the [REST API](../rest-api/overview), you can use the full power of Payload
To understand the syntax, you need to understand that complex URL search strings are parsed into a JSON object. This one isn't too bad, but more complex queries get unavoidably more difficult to write.
For this reason, we recommend to use the extremely helpful and ubiquitous [`qs`](https://www.npmjs.com/package/qs) package to parse your JSON / object-formatted queries into query strings:
For this reason, we recommend to use the extremely helpful and ubiquitous [`qs-esm`](https://www.npmjs.com/package/qs-esm) package to parse your JSON / object-formatted queries into query strings:
```ts
import { stringify } from 'qs-esm'

View File

@@ -6,11 +6,13 @@ desc: Payload select determines which fields are selected to the result.
keywords: query, documents, pagination, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
You may not need the full data from your Local API / REST queries, but only some specific fields. The select fields API can help you to optimize those cases.
By default, Payload's APIs will return _all fields_ for a given collection or global. But, you may not need all of that data for all of your queries. Sometimes, you might want just a few fields from the response, which can speed up the Payload API and reduce the amount of JSON that is sent to you from the API.
This is where Payload's `select` feature comes in. Here, you can define exactly which fields you'd like to retrieve from the API.
## Local API
To specify select in the [Local API](../local-api/overview), you can use the `select` option in your query:
To specify `select` in the [Local API](../local-api/overview), you can use the `select` option in your query:
```ts
// Include mode
@@ -51,7 +53,7 @@ const getPosts = async () => {
<Banner type="warning">
<strong>Important:</strong>
To perform querying with `select` efficiently, it works on the database level. Because of that, your `beforeRead` and `afterRead` hooks may not receive the full `doc`.
To perform querying with `select` efficiently, Payload implements your `select` query on the database level. Because of that, your `beforeRead` and `afterRead` hooks may not receive the full `doc`.
</Banner>
@@ -67,7 +69,7 @@ fetch('https://localhost:3000/api/posts?select[color]=true&select[group][number]
To understand the syntax, you need to understand that complex URL search strings are parsed into a JSON object. This one isn't too bad, but more complex queries get unavoidably more difficult to write.
For this reason, we recommend to use the extremely helpful and ubiquitous [`qs`](https://www.npmjs.com/package/qs) package to parse your JSON / object-formatted queries into query strings:
For this reason, we recommend to use the extremely helpful and ubiquitous [`qs-esm`](https://www.npmjs.com/package/qs-esm) package to parse your JSON / object-formatted queries into query strings:
```ts
import { stringify } from 'qs-esm'
@@ -100,11 +102,17 @@ const getPosts = async () => {
</Banner>
## `defaultPopulate` collection config property
## defaultPopulate collection config property
The `defaultPopulate` property allows you specify which fields to select when populating the collection from another document.
This is especially useful for links where only the `slug` is needed instead of the entire document.
With this feature, you can dramatically reduce the amount of JSON that is populated from [Relationship](/docs/beta/fields/relationship) or [Upload](/docs/beta/fields/upload) fields.
For example, in your content model, you might have a `Link` field which links out to a different page. When you go to retrieve these links, you really only need the `slug` of the page.
Loading all of the page content, its related links, and everything else is going to be overkill and will bog down your Payload APIs. Instead, you can define the `defaultPopulate` property on your `Pages` collection, so that when Payload "populates" a related Page, it only selects the `slug` field and therefore returns significantly less JSON:
```ts
import type { CollectionConfig } from 'payload'
@@ -129,17 +137,20 @@ export const Pages: CollectionConfig<'pages'> = {
}
```
## `populate`
## populate
You can override `defaultPopulate` with the `populate` property in the Local and REST API
Setting `defaultPopulate` will enforce that each time Payload performs a "population" of a related document, only the fields specified will be queried and returned. However, you can override `defaultPopulate` with the `populate` property in the Local and REST API:
**Local API:**
Local API:
```ts
const getPosts = async () => {
const posts = await payload.find({
collection: 'posts',
populate: {
// Select only `text` from populated docs in the "pages" collection
// Now, no matter what the `defaultPopulate` is set to on the "pages" collection,
// it will be overridden, and the `text` field will be returned instead.
pages: {
text: true,
}, // highlight-line
@@ -150,7 +161,8 @@ const getPosts = async () => {
}
```
REST API:
**REST API:**
```ts
fetch('https://localhost:3000/api/posts?populate[pages][text]=true') // highlight-line
.then((res) => res.json())

View File

@@ -140,6 +140,23 @@ export default buildConfig({
})
```
### Custom filename via hooks
You can customize the filename before it's uploaded to the server by using a `beforeOperation` hook.
```ts
beforeOperation: [
({ req, operation }) => {
if ((operation === 'create' || operation === 'update') && req.file) {
req.file.name = 'test.jpg'
}
},
],
```
The `req.file` object will have additional information about the file, such as mimeType and extension, and you also have full access to the file data itself.
The filename from here will also be threaded to image sizes if they're enabled.
## Image Sizes
If you specify an array of `imageSizes` to your `upload` config, Payload will automatically crop and resize your uploads to fit each of the sizes specified by your config.

View File

@@ -1,6 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import configPromise from '@payload-config'
import config from '@payload-config'
import '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
@@ -13,7 +13,7 @@ type Args = {
}
const Layout = ({ children }: Args) => (
<RootLayout config={configPromise} importMap={importMap}>
<RootLayout config={config} importMap={importMap}>
{children}
</RootLayout>
)

View File

@@ -1,8 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import configPromise from '@payload-config'
import '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import React from 'react'
import './custom.scss'

View File

@@ -1,6 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import configPromise from '@payload-config'
import config from '@payload-config'
import '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
@@ -13,7 +13,7 @@ type Args = {
}
const Layout = ({ children }: Args) => (
<RootLayout config={configPromise} importMap={importMap}>
<RootLayout config={config} importMap={importMap}>
{children}
</RootLayout>
)

View File

@@ -20,7 +20,9 @@ export const Login = ({ tenantSlug }: Props) => {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!usernameRef?.current?.value || !passwordRef?.current?.value) {return}
if (!usernameRef?.current?.value || !passwordRef?.current?.value) {
return
}
const actionRes = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/external-users/login`,
{

View File

@@ -6,7 +6,9 @@ import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation
if (originalDoc.slug === value) {return value}
if (originalDoc.slug === value) {
return value
}
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
const currentTenantID =

View File

@@ -14,7 +14,9 @@ export const Pages: CollectionConfig = {
read: (args) => {
// when viewing pages inside the admin panel
// restrict access to the ones your user has access to
if (isPayloadAdminPanel(args.req)) {return filterByTenantRead(args)}
if (isPayloadAdminPanel(args.req)) {
return filterByTenantRead(args)
}
// when viewing pages from outside the admin panel
// you should be able to see your tenants and public tenants

View File

@@ -7,7 +7,9 @@ export const tenantRead: Access = (args) => {
const req = args.req
// Super admin can read all
if (isSuperAdmin(args)) {return true}
if (isSuperAdmin(args)) {
return true
}
const tenantIDs = getTenantAccessIDs(req.user)

View File

@@ -7,13 +7,19 @@ import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAcces
export const createAccess: Access<User> = (args) => {
const { req } = args
if (!req.user) {return false}
if (!req.user) {
return false
}
if (isSuperAdmin(args)) {return true}
if (isSuperAdmin(args)) {
return true
}
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
if (adminTenantAccessIDs.length > 0) {return true}
if (adminTenantAccessIDs.length > 0) {
return true
}
return false
}

View File

@@ -1,6 +1,8 @@
import type { Access } from 'payload'
export const isAccessingSelf: Access = ({ id, req }) => {
if (!req?.user) {return false}
if (!req?.user) {
return false
}
return req.user.id === id
}

View File

@@ -5,9 +5,13 @@ import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAcces
export const updateAndDeleteAccess: Access = (args) => {
const { req } = args
if (!req.user) {return false}
if (!req.user) {
return false
}
if (isSuperAdmin(args)) {return true}
if (isSuperAdmin(args)) {
return true
}
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)

View File

@@ -6,7 +6,9 @@ import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation
if (originalDoc.username === value) {return value}
if (originalDoc.username === value) {
return value
}
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
const currentTenantID =

View File

@@ -8,7 +8,9 @@ export const autofillTenant: FieldHook = ({ req, value }) => {
// return that tenant ID as the value
if (!value) {
const tenantIDs = getTenantAccessIDs(req.user)
if (tenantIDs.length === 1) {return tenantIDs[0]}
if (tenantIDs.length === 1) {
return tenantIDs[0]
}
}
return value

View File

@@ -10,7 +10,9 @@ export const tenantField: Field = {
access: {
read: () => true,
update: (args) => {
if (isSuperAdmin(args)) {return true}
if (isSuperAdmin(args)) {
return true
}
return tenantFieldUpdate(args)
},
},

View File

@@ -1,7 +1,9 @@
import type { User } from '../payload-types'
export const getTenantAccessIDs = (user: null | User): string[] => {
if (!user) {return []}
if (!user) {
return []
}
return (
user?.tenants?.reduce((acc: string[], { tenant }) => {
if (tenant) {
@@ -13,7 +15,9 @@ export const getTenantAccessIDs = (user: null | User): string[] => {
}
export const getTenantAdminTenantAccessIDs = (user: null | User): string[] => {
if (!user) {return []}
if (!user) {
return []
}
return (
user?.tenants?.reduce((acc: string[], { roles, tenant }) => {

View File

@@ -1,6 +1,6 @@
(The MIT License)
MIT License
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the

View File

@@ -19,6 +19,11 @@ const config = withBundleAnalyzer(
typescript: {
ignoreBuildErrors: true,
},
experimental: {
serverActions: {
bodySizeLimit: '5mb',
},
},
env: {
PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname),

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"private": true,
"type": "module",
"scripts": {
@@ -58,6 +58,8 @@
"dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts",
"dev:generate-types": "pnpm runts ./test/generateTypes.ts",
"dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts",
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/dev.ts --prod",
"dev:prod:memorydb": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/dev.ts --prod --start-memory-db",
"dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-postgres pnpm runts ./test/dev.ts",
"devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
"docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start",
@@ -69,8 +71,8 @@
"lint:fix": "turbo run lint:fix --concurrency 1 --continue",
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
"prepare": "husky",
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
"prepare-run-test-against-prod:ci": "rm -rf test/node_modules && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
"prepare-run-test-against-prod:ci": "rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
"reinstall": "pnpm clean:all && pnpm install",
"release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha",
"release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta",

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -8,6 +8,14 @@
"directory": "packages/create-payload-app"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
"./types": {

View File

@@ -78,15 +78,14 @@ export async function createProject(args: {
)
await fse.copy(localTemplate, projectDir)
} else if ('url' in template) {
let templateUrl = template.url
if (cliArgs['--template-branch']) {
templateUrl = `${template.url}#${cliArgs['--template-branch']}`
debug(`Using template url: ${templateUrl}`)
template.url = `${template.url.split('#')?.[0]}#${cliArgs['--template-branch']}`
}
await downloadTemplate({
name: template.name,
branch: 'beta',
debug: cliArgs['--debug'],
projectDir,
template,
})
}

View File

@@ -2,27 +2,35 @@ import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { x } from 'tar'
import type { ProjectTemplate } from '../types.js'
import { debug as debugLog } from '../utils/log.js'
export async function downloadTemplate({
name,
branch,
debug,
projectDir,
template,
}: {
branch: string
/**
* The name of the template to download
* Must be dir /templates/<name>
*/
name: string
debug?: boolean
projectDir: string
template: ProjectTemplate
}) {
const url = `https://codeload.github.com/payloadcms/payload/tar.gz/${branch}`
const filter = `payload-${branch}/templates/${name}/`
const branchOrTag = template.url.split('#')?.[1] || 'beta'
const url = `https://codeload.github.com/payloadcms/payload/tar.gz/${branchOrTag}`
const filter = `payload-${branchOrTag.replace(/^v/, '')}/templates/${template.name}/`
if (debug) {
debugLog(`Using template url: ${template.url}`)
debugLog(`Codeload url: ${url}`)
debugLog(`Filter: ${filter}`)
}
await pipeline(
await downloadTarStream(url),
x({
cwd: projectDir,
filter: (p) => p.includes(filter),
strip: 2 + name.split('/').length,
strip: 2 + template.name.split('/').length,
}),
)
}

View File

@@ -32,13 +32,13 @@ type InitNextArgs = {
} & Pick<CliArgs, '--debug'>
type InitNextResult =
| { isSrcDir: boolean; nextAppDir?: string; reason: string; success: false }
| {
isSrcDir: boolean
nextAppDir: string
payloadConfigPath: string
success: true
}
| { isSrcDir: boolean; nextAppDir?: string; reason: string; success: false }
export async function initNext(args: InitNextArgs): Promise<InitNextResult> {
const { dbType: dbType, packageManager, projectDir } = args

View File

@@ -15,15 +15,9 @@ export async function installPackages(args: {
let stderr = ''
switch (packageManager) {
case 'npm': {
;({ exitCode, stderr } = await execa('npm', ['install', '--save', ...packagesToInstall], {
cwd: projectDir,
}))
break
}
case 'yarn':
case 'bun':
case 'pnpm':
case 'bun': {
case 'yarn': {
if (packageManager === 'bun') {
warning('Bun support is untested.')
}
@@ -32,6 +26,12 @@ export async function installPackages(args: {
}))
break
}
case 'npm': {
;({ exitCode, stderr } = await execa('npm', ['install', '--save', ...packagesToInstall], {
cwd: projectDir,
}))
break
}
}
if (exitCode !== 0) {

View File

@@ -14,6 +14,7 @@ export function validateTemplate(templateName: string): boolean {
}
export function getValidTemplates(): ProjectTemplate[] {
// Starters _must_ be a valid template name from the templates/ directory
return [
{
name: 'blank',
@@ -28,37 +29,11 @@ export function getValidTemplates(): ProjectTemplate[] {
url: `https://github.com/payloadcms/payload/templates/website#v${PACKAGE_VERSION}`,
},
// Remove these until they have been updated for 3.0
// {
// name: 'blank',
// type: 'starter',
// description: 'Blank Template',
// url: 'https://github.com/payloadcms/payload/templates/blank',
// },
// {
// name: 'ecommerce',
// type: 'starter',
// description: 'E-commerce Template',
// url: 'https://github.com/payloadcms/payload/templates/ecommerce',
// },
// {
// name: 'plugin',
// type: 'plugin',
// description: 'Template for creating a Payload plugin',
// url: 'https://github.com/payloadcms/payload-plugin-template#beta',
// },
// {
// name: 'payload-demo',
// type: 'starter',
// description: 'Payload demo site at https://demo.payloadcms.com',
// url: 'https://github.com/payloadcms/public-demo',
// },
// {
// name: 'payload-website',
// type: 'starter',
// description: 'Payload website CMS at https://payloadcms.com',
// url: 'https://github.com/payloadcms/website-cms',
// url: 'https://github.com/payloadcms/plugin-template#beta',
// },
]
}

View File

@@ -205,7 +205,7 @@ export class Main {
}
if (debugFlag) {
debug(`Using templates from git tag: ${PACKAGE_VERSION}`)
debug(`Using templates from git tag: v${PACKAGE_VERSION}`)
}
const validTemplates = getValidTemplates()
@@ -217,6 +217,16 @@ export class Main {
}
switch (template.type) {
case 'plugin': {
await createProject({
cliArgs: this.args,
packageManager,
projectDir,
projectName,
template,
})
break
}
case 'starter': {
const dbDetails = await selectDb(this.args, projectName)
const payloadSecret = generateSecret()
@@ -238,16 +248,6 @@ export class Main {
})
break
}
case 'plugin': {
await createProject({
cliArgs: this.args,
packageManager,
projectDir,
projectName,
template,
})
break
}
}
info('Payload project successfully created!')

View File

@@ -6,13 +6,16 @@ import path from 'path'
*
* @internal
*/
export function copyRecursiveSync(src: string, dest: string) {
export function copyRecursiveSync(src: string, dest: string, ignoreRegex?: string[]): void {
const exists = fs.existsSync(src)
const stats = exists && fs.statSync(src)
const isDirectory = exists && stats !== false && stats.isDirectory()
if (isDirectory) {
fs.mkdirSync(dest, { recursive: true })
fs.readdirSync(src).forEach((childItemName) => {
if (ignoreRegex && ignoreRegex.some((regex) => new RegExp(regex).test(childItemName))) {
return
}
copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName))
})
} else {

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -10,6 +10,13 @@
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {

View File

@@ -57,6 +57,21 @@ 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)
})
})
}),
)
}
if (process.env.NODE_ENV === 'production' && this.prodMigrations) {
await this.migrate({ migrations: this.prodMigrations })
}

View File

@@ -0,0 +1,48 @@
import type { QueryOptions } from 'mongoose'
import type { CountGlobalVersions, PayloadRequest } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { withSession } from './withSession.js'
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
this: MongooseAdapter,
{ global, locale, req = {} as PayloadRequest, where },
) {
const Model = this.versions[global]
const options: QueryOptions = await withSession(this, req)
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const query = await Model.buildQuery({
locale,
payload: this.payload,
where,
})
// 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
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
const result = await Model.countDocuments(query, options)
return {
totalDocs: result,
}
}

View File

@@ -0,0 +1,48 @@
import type { QueryOptions } from 'mongoose'
import type { CountVersions, PayloadRequest } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { withSession } from './withSession.js'
export const countVersions: CountVersions = async function countVersions(
this: MongooseAdapter,
{ collection, locale, req = {} as PayloadRequest, where },
) {
const Model = this.versions[collection]
const options: QueryOptions = await withSession(this, req)
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const query = await Model.buildQuery({
locale,
payload: this.payload,
where,
})
// 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
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
const result = await Model.countDocuments(query, options)
return {
totalDocs: result,
}
}

View File

@@ -12,6 +12,8 @@ import type { CollectionModel, GlobalModel, MigrateDownArgs, MigrateUpArgs } fro
import { connect } from './connect.js'
import { count } from './count.js'
import { countGlobalVersions } from './countGlobalVersions.js'
import { countVersions } from './countVersions.js'
import { create } from './create.js'
import { createGlobal } from './createGlobal.js'
import { createGlobalVersion } from './createGlobalVersion.js'
@@ -72,8 +74,14 @@ export interface Args {
/** Set false to disable $facet aggregation in non-supporting databases, Defaults to true */
useFacet?: boolean
} & ConnectOptions
/** Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false */
disableIndexHints?: boolean
/**
* Set to `true` to ensure that indexes are ready before completing connection.
* NOTE: not recommended for production. This can slow down the initialization of Payload.
*/
ensureIndexes?: boolean
migrationDir?: string
/**
* typed as any to avoid dependency
@@ -94,6 +102,7 @@ export type MongooseAdapter = {
[slug: string]: CollectionModel
}
connection: Connection
ensureIndexes: boolean
globals: GlobalModel
mongoMemoryServer: MongoMemoryReplSet
prodMigrations?: {
@@ -116,6 +125,7 @@ declare module 'payload' {
[slug: string]: CollectionModel
}
connection: Connection
ensureIndexes: boolean
globals: GlobalModel
mongoMemoryServer: MongoMemoryReplSet
prodMigrations?: {
@@ -136,6 +146,7 @@ export function mongooseAdapter({
autoPluralization = true,
connectOptions,
disableIndexHints = false,
ensureIndexes,
migrationDir: migrationDirArg,
mongoMemoryServer,
prodMigrations,
@@ -154,8 +165,8 @@ export function mongooseAdapter({
collections: {},
connection: undefined,
connectOptions: connectOptions || {},
count,
disableIndexHints,
ensureIndexes,
globals: undefined,
mongoMemoryServer,
sessions: {},
@@ -166,6 +177,9 @@ export function mongooseAdapter({
beginTransaction: transactionOptions === false ? defaultBeginTransaction() : beginTransaction,
commitTransaction,
connect,
count,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,

View File

@@ -45,25 +45,28 @@ export const init: Init = function init(this: MongooseAdapter) {
versionSchema.plugin(mongooseAggregatePaginate)
}
const model = mongoose.model(
const versionCollectionName =
this.autoPluralization === true && !collection.dbName ? undefined : versionModelName
this.versions[collection.slug] = mongoose.model(
versionModelName,
versionSchema,
this.autoPluralization === true ? undefined : versionModelName,
versionCollectionName,
) as CollectionModel
this.versions[collection.slug] = model
}
const model = mongoose.model(
getDBName({ config: collection }),
const modelName = getDBName({ config: collection })
const collectionName =
this.autoPluralization === true && !collection.dbName ? undefined : modelName
this.collections[collection.slug] = mongoose.model(
modelName,
schema,
this.autoPluralization === true ? undefined : collection.slug,
collectionName,
) as CollectionModel
this.collections[collection.slug] = model
})
const model = buildGlobalModel(this.payload.config)
this.globals = model
this.globals = buildGlobalModel(this.payload.config)
this.payload.config.globals.forEach((global) => {
if (global.versions) {
@@ -85,12 +88,11 @@ export const init: Init = function init(this: MongooseAdapter) {
.plugin<any, PaginateOptions>(paginate, { useEstimatedCount: true })
.plugin(getBuildQueryPlugin({ versionsFields: versionGlobalFields }))
const versionsModel = mongoose.model(
this.versions[global.slug] = mongoose.model(
versionModelName,
versionSchema,
versionModelName,
) as CollectionModel
this.versions[global.slug] = versionsModel
}
})
}

View File

@@ -89,11 +89,7 @@ export async function buildSearchParam({
const [{ field, path }] = paths
if (path) {
const {
operator: formattedOperator,
rawQuery,
val: formattedValue,
} = sanitizeQueryValue({
const sanitizedQueryValue = sanitizeQueryValue({
field,
hasCustomID,
operator,
@@ -101,6 +97,12 @@ export async function buildSearchParam({
val,
})
if (!sanitizedQueryValue) {
return undefined
}
const { operator: formattedOperator, rawQuery, val: formattedValue } = sanitizedQueryValue
if (rawQuery) {
return { value: rawQuery }
}

View File

@@ -1,8 +1,6 @@
import type { PipelineStage } from 'mongoose'
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
import { combineQueries } from 'payload'
import type { MongooseAdapter } from '../index.js'
import { buildSortParam } from '../queries/buildSortParam.js'
@@ -60,11 +58,11 @@ export const buildJoinAggregation = async ({
for (const join of joinConfig[slug]) {
const joinModel = adapter.collections[join.field.collection]
if (projection && !projection[join.schemaPath]) {
if (projection && !projection[join.joinPath]) {
continue
}
if (joins?.[join.schemaPath] === false) {
if (joins?.[join.joinPath] === false) {
continue
}
@@ -72,7 +70,7 @@ export const buildJoinAggregation = async ({
limit: limitJoin = join.field.defaultLimit ?? 10,
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
where: whereJoin,
} = joins?.[join.schemaPath] || {}
} = joins?.[join.joinPath] || {}
const sort = buildSortParam({
config: adapter.payload.config,
@@ -105,7 +103,7 @@ export const buildJoinAggregation = async ({
if (adapter.payload.config.localization && locale === 'all') {
adapter.payload.config.localization.localeCodes.forEach((code) => {
const as = `${versions ? `version.${join.schemaPath}` : join.schemaPath}${code}`
const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${code}`
aggregate.push(
{
@@ -146,7 +144,7 @@ export const buildJoinAggregation = async ({
} else {
const localeSuffix =
join.field.localized && adapter.payload.config.localization && locale ? `.${locale}` : ''
const as = `${versions ? `version.${join.schemaPath}` : join.schemaPath}${localeSuffix}`
const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${localeSuffix}`
aggregate.push(
{

View File

@@ -104,34 +104,10 @@ const traverseFields = ({
}
switch (field.type) {
case 'collapsible':
case 'row':
traverseFields({
adapter,
databaseSchemaPath,
fields: field.fields,
projection,
select,
selectMode,
withinLocalizedField,
})
break
case 'tabs':
traverseFields({
adapter,
databaseSchemaPath,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
projection,
select,
selectMode,
withinLocalizedField,
})
break
case 'array':
case 'group':
case 'tab':
case 'array': {
case 'tab': {
let fieldSelect: SelectType
if (field.type === 'tab' && !tabHasName(field)) {
@@ -206,6 +182,30 @@ const traverseFields = ({
break
}
case 'collapsible':
case 'row':
traverseFields({
adapter,
databaseSchemaPath,
fields: field.fields,
projection,
select,
selectMode,
withinLocalizedField,
})
break
case 'tabs':
traverseFields({
adapter,
databaseSchemaPath,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
projection,
select,
selectMode,
withinLocalizedField,
})
break
default:
break

View File

@@ -19,8 +19,8 @@ export const handleError = ({
collection,
errors: [
{
field: Object.keys(error.keyValue)[0],
message: req.t('error:valueMustBeUnique'),
path: Object.keys(error.keyValue)[0],
},
],
global,

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -10,6 +10,13 @@
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {

View File

@@ -4,6 +4,8 @@ import {
beginTransaction,
commitTransaction,
count,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,
@@ -33,7 +35,6 @@ import {
updateVersion,
} from '@payloadcms/drizzle'
import {
convertPathToJSONTraversal,
countDistinct,
createDatabase,
createExtensions,
@@ -49,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'
@@ -87,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,
@@ -123,14 +132,14 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
args.transactionOptions === false ? defaultBeginTransaction() : beginTransaction,
commitTransaction,
connect,
convertPathToJSONTraversal,
count,
countDistinct,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,
createJSONQuery,
createMigration,
createVersion,
defaultIDType: payloadIDType,
deleteMany,

View File

@@ -13,44 +13,6 @@ type Args = {
export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
fields.forEach((field) => {
switch (field.type) {
case 'group': {
const newPath = `${path ? `${path}.` : ''}${field.name}`
const newDoc = doc?.[field.name]
if (typeof newDoc === 'object' && newDoc !== null) {
if (field.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: field.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: field.fields,
path: newPath,
rows,
})
}
}
break
}
case 'row':
case 'collapsible': {
return traverseFields({
doc,
fields: field.fields,
path,
rows,
})
}
case 'array': {
const rowData = doc?.[field.name]
@@ -124,45 +86,47 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
break
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
const newDoc = doc?.[tab.name]
const newPath = `${path ? `${path}.` : ''}${tab.name}`
if (typeof newDoc === 'object' && newDoc !== null) {
if (tab.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: tab.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: tab.fields,
path: newPath,
rows,
})
}
}
} else {
traverseFields({
doc,
fields: tab.fields,
path,
rows,
})
}
case 'collapsible':
// falls through
case 'row': {
return traverseFields({
doc,
fields: field.fields,
path,
rows,
})
}
case 'group': {
const newPath = `${path ? `${path}.` : ''}${field.name}`
const newDoc = doc?.[field.name]
if (typeof newDoc === 'object' && newDoc !== null) {
if (field.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: field.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: field.fields,
path: newPath,
rows,
})
}
}
break
}
case 'relationship':
// falls through
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
@@ -211,6 +175,43 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
}
}
}
break
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
const newDoc = doc?.[tab.name]
const newPath = `${path ? `${path}.` : ''}${tab.name}`
if (typeof newDoc === 'object' && newDoc !== null) {
if (tab.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: tab.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: tab.fields,
path: newPath,
rows,
})
}
}
} else {
traverseFields({
doc,
fields: tab.fields,
path,
rows,
})
}
})
}
}
})

View File

@@ -27,30 +27,6 @@ type Args = {
export const traverseFields = (args: Args) => {
args.fields.forEach((field) => {
switch (field.type) {
case 'group': {
let newTableName = `${args.newTableName}_${toSnakeCase(field.name)}`
if (field.localized && args.payload.config.localization) {
newTableName += args.adapter.localesSuffix
}
return traverseFields({
...args,
columnPrefix: `${args.columnPrefix}${toSnakeCase(field.name)}_`,
fields: field.fields,
newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}`,
})
}
case 'row':
case 'collapsible': {
return traverseFields({
...args,
fields: field.fields,
})
}
case 'array': {
const newTableName = args.adapter.tableNameMap.get(
`${args.newTableName}_${toSnakeCase(field.name)}`,
@@ -82,7 +58,42 @@ export const traverseFields = (args: Args) => {
})
})
}
case 'collapsible':
case 'row': {
return traverseFields({
...args,
fields: field.fields,
})
}
case 'group': {
let newTableName = `${args.newTableName}_${toSnakeCase(field.name)}`
if (field.localized && args.payload.config.localization) {
newTableName += args.adapter.localesSuffix
}
return traverseFields({
...args,
columnPrefix: `${args.columnPrefix}${toSnakeCase(field.name)}_`,
fields: field.fields,
newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}`,
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
args.pathsToQuery.add(`${args.path ? `${args.path}.` : ''}${field.name}`)
}
}
return null
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
@@ -101,17 +112,6 @@ export const traverseFields = (args: Args) => {
})
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
args.pathsToQuery.add(`${args.path ? `${args.path}.` : ''}${field.name}`)
}
}
return null
}
}
})
}

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -10,6 +10,13 @@
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {

View File

@@ -5,6 +5,8 @@ import {
beginTransaction,
commitTransaction,
count,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,
@@ -114,6 +116,8 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
convertPathToJSONTraversal,
count,
countDistinct,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,

View File

@@ -179,183 +179,6 @@ export const traverseFields = ({
}
switch (field.type) {
case 'text': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyTextField = true
}
if (field.index) {
hasManyTextField = 'index'
} else if (!hasManyTextField) {
hasManyTextField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in SQLite for hasMany text fields.',
)
}
} else {
targetTable[fieldName] = withDefault(text(columnName), field)
}
break
}
case 'email':
case 'code':
case 'textarea': {
targetTable[fieldName] = withDefault(text(columnName), field)
break
}
case 'number': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyNumberField = true
}
if (field.index) {
hasManyNumberField = 'index'
} else if (!hasManyNumberField) {
hasManyNumberField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany number fields.',
)
}
} else {
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'richText':
case 'json': {
targetTable[fieldName] = withDefault(text(columnName, { mode: 'json' }), field)
break
}
case 'date': {
targetTable[fieldName] = withDefault(text(columnName), field)
break
}
case 'point': {
break
}
case 'radio':
case 'select': {
const options = field.options.map((option) => {
if (optionIsObject(option)) {
return option.value
}
return option
}) as [string, ...string[]]
if (field.type === 'select' && field.hasMany) {
const selectTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
versionsCustomName: versions,
})
const baseColumns: Record<string, SQLiteColumnBuilder> = {
order: integer('order').notNull(),
parent: getIDColumn({
name: 'parent_id',
type: parentIDColType,
notNull: true,
primaryKey: false,
}),
value: text('value', { enum: options }),
}
const baseExtraConfig: BaseExtraConfig = {
orderIdx: (cols) => index(`${selectTableName}_order_idx`).on(cols.order),
parentFk: (cols) =>
foreignKey({
name: `${selectTableName}_parent_fk`,
columns: [cols.parent],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
parentIdx: (cols) => index(`${selectTableName}_parent_idx`).on(cols.parent),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns.locale = text('locale', { enum: locales }).notNull()
baseExtraConfig.localeIdx = (cols) =>
index(`${selectTableName}_locale_idx`).on(cols.locale)
}
if (field.index) {
baseExtraConfig.value = (cols) => index(`${selectTableName}_value_idx`).on(cols.value)
}
buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull,
disableUnique,
fields: [],
rootTableName,
tableName: selectTableName,
versions,
})
relationsToBuild.set(fieldName, {
type: 'many',
// selects have their own localized table, independent of the base table.
localized: false,
target: selectTableName,
})
adapter.relations[`relations_${selectTableName}`] = relations(
adapter.tables[selectTableName],
({ one }) => ({
parent: one(adapter.tables[parentTableName], {
fields: [adapter.tables[selectTableName].parent],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}),
)
} else {
targetTable[fieldName] = withDefault(
text(columnName, {
enum: options,
}),
field,
)
}
break
}
case 'checkbox': {
targetTable[fieldName] = withDefault(integer(columnName, { mode: 'boolean' }), field)
break
}
case 'array': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
@@ -493,7 +316,6 @@ export const traverseFields = ({
break
}
case 'blocks': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
@@ -646,9 +468,82 @@ export const traverseFields = ({
break
}
case 'checkbox': {
targetTable[fieldName] = withDefault(integer(columnName, { mode: 'boolean' }), field)
break
}
case 'code':
case 'tab':
case 'group': {
case 'email':
case 'textarea': {
targetTable[fieldName] = withDefault(text(columnName), field)
break
}
case 'collapsible':
case 'row': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: rowHasLocalizedField,
hasLocalizedManyNumberField: rowHasLocalizedManyNumberField,
hasLocalizedManyTextField: rowHasLocalizedManyTextField,
hasLocalizedRelationshipField: rowHasLocalizedRelationshipField,
hasManyNumberField: rowHasManyNumberField,
hasManyTextField: rowHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
locales,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (rowHasLocalizedField) {
hasLocalizedField = true
}
if (rowHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (rowHasManyTextField) {
hasManyTextField = true
}
if (rowHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (rowHasManyNumberField) {
hasManyNumberField = true
}
if (rowHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'date': {
targetTable[fieldName] = withDefault(text(columnName), field)
break
}
case 'group':
case 'tab': {
if (!('name' in field)) {
const {
hasLocalizedField: groupHasLocalizedField,
@@ -758,114 +653,136 @@ export const traverseFields = ({
break
}
case 'tabs': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
case 'json':
const {
hasLocalizedField: tabHasLocalizedField,
hasLocalizedManyNumberField: tabHasLocalizedManyNumberField,
hasLocalizedManyTextField: tabHasLocalizedManyTextField,
hasLocalizedRelationshipField: tabHasLocalizedRelationshipField,
hasManyNumberField: tabHasManyNumberField,
hasManyTextField: tabHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
forceLocalized,
indexes,
locales,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
case 'richText': {
targetTable[fieldName] = withDefault(text(columnName, { mode: 'json' }), field)
break
}
if (tabHasLocalizedField) {
hasLocalizedField = true
}
if (tabHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (tabHasManyTextField) {
hasManyTextField = true
}
if (tabHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (tabHasManyNumberField) {
hasManyNumberField = true
}
if (tabHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
case 'number': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyNumberField = true
}
if (field.index) {
hasManyNumberField = 'index'
} else if (!hasManyNumberField) {
hasManyNumberField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany number fields.',
)
}
} else {
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'row':
case 'collapsible': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: rowHasLocalizedField,
hasLocalizedManyNumberField: rowHasLocalizedManyNumberField,
hasLocalizedManyTextField: rowHasLocalizedManyTextField,
hasLocalizedRelationshipField: rowHasLocalizedRelationshipField,
hasManyNumberField: rowHasManyNumberField,
hasManyTextField: rowHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
locales,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
case 'point': {
break
}
case 'radio':
if (rowHasLocalizedField) {
hasLocalizedField = true
}
if (rowHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (rowHasManyTextField) {
hasManyTextField = true
}
if (rowHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (rowHasManyNumberField) {
hasManyNumberField = true
}
if (rowHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
case 'select': {
const options = field.options.map((option) => {
if (optionIsObject(option)) {
return option.value
}
return option
}) as [string, ...string[]]
if (field.type === 'select' && field.hasMany) {
const selectTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
versionsCustomName: versions,
})
const baseColumns: Record<string, SQLiteColumnBuilder> = {
order: integer('order').notNull(),
parent: getIDColumn({
name: 'parent_id',
type: parentIDColType,
notNull: true,
primaryKey: false,
}),
value: text('value', { enum: options }),
}
const baseExtraConfig: BaseExtraConfig = {
orderIdx: (cols) => index(`${selectTableName}_order_idx`).on(cols.order),
parentFk: (cols) =>
foreignKey({
name: `${selectTableName}_parent_fk`,
columns: [cols.parent],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
parentIdx: (cols) => index(`${selectTableName}_parent_idx`).on(cols.parent),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns.locale = text('locale', { enum: locales }).notNull()
baseExtraConfig.localeIdx = (cols) =>
index(`${selectTableName}_locale_idx`).on(cols.locale)
}
if (field.index) {
baseExtraConfig.value = (cols) => index(`${selectTableName}_value_idx`).on(cols.value)
}
buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull,
disableUnique,
fields: [],
rootTableName,
tableName: selectTableName,
versions,
})
relationsToBuild.set(fieldName, {
type: 'many',
// selects have their own localized table, independent of the base table.
localized: false,
target: selectTableName,
})
adapter.relations[`relations_${selectTableName}`] = relations(
adapter.tables[selectTableName],
({ one }) => ({
parent: one(adapter.tables[parentTableName], {
fields: [adapter.tables[selectTableName].parent],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}),
)
} else {
targetTable[fieldName] = withDefault(
text(columnName, {
enum: options,
}),
field,
)
}
break
}
@@ -931,6 +848,89 @@ export const traverseFields = ({
break
case 'tabs': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: tabHasLocalizedField,
hasLocalizedManyNumberField: tabHasLocalizedManyNumberField,
hasLocalizedManyTextField: tabHasLocalizedManyTextField,
hasLocalizedRelationshipField: tabHasLocalizedRelationshipField,
hasManyNumberField: tabHasManyNumberField,
hasManyTextField: tabHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
forceLocalized,
indexes,
locales,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (tabHasLocalizedField) {
hasLocalizedField = true
}
if (tabHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (tabHasManyTextField) {
hasManyTextField = true
}
if (tabHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (tabHasManyNumberField) {
hasManyNumberField = true
}
if (tabHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'text': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyTextField = true
}
if (field.index) {
hasManyTextField = 'index'
} else if (!hasManyTextField) {
hasManyTextField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in SQLite for hasMany text fields.',
)
}
} else {
targetTable[fieldName] = withDefault(text(columnName), field)
}
break
}
default:
break
}

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -10,6 +10,13 @@
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {

View File

@@ -4,6 +4,8 @@ import {
beginTransaction,
commitTransaction,
count,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,
@@ -33,7 +35,6 @@ import {
updateVersion,
} from '@payloadcms/drizzle'
import {
convertPathToJSONTraversal,
countDistinct,
createDatabase,
createExtensions,
@@ -49,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'
@@ -124,14 +130,17 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
args.transactionOptions === false ? defaultBeginTransaction() : beginTransaction,
commitTransaction,
connect,
convertPathToJSONTraversal,
count,
countDistinct,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,
createJSONQuery,
createMigration,
createMigration(args) {
return createMigration.bind(this)({ ...args, dirname })
},
createVersion,
defaultIDType: payloadIDType,
deleteMany,

View File

@@ -13,44 +13,6 @@ type Args = {
export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
fields.forEach((field) => {
switch (field.type) {
case 'group': {
const newPath = `${path ? `${path}.` : ''}${field.name}`
const newDoc = doc?.[field.name]
if (typeof newDoc === 'object' && newDoc !== null) {
if (field.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: field.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: field.fields,
path: newPath,
rows,
})
}
}
break
}
case 'row':
case 'collapsible': {
return traverseFields({
doc,
fields: field.fields,
path,
rows,
})
}
case 'array': {
const rowData = doc?.[field.name]
@@ -124,45 +86,47 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
break
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
const newDoc = doc?.[tab.name]
const newPath = `${path ? `${path}.` : ''}${tab.name}`
if (typeof newDoc === 'object' && newDoc !== null) {
if (tab.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: tab.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: tab.fields,
path: newPath,
rows,
})
}
}
} else {
traverseFields({
doc,
fields: tab.fields,
path,
rows,
})
}
case 'collapsible':
// falls through
case 'row': {
return traverseFields({
doc,
fields: field.fields,
path,
rows,
})
}
case 'group': {
const newPath = `${path ? `${path}.` : ''}${field.name}`
const newDoc = doc?.[field.name]
if (typeof newDoc === 'object' && newDoc !== null) {
if (field.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: field.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: field.fields,
path: newPath,
rows,
})
}
}
break
}
case 'relationship':
// falls through
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
@@ -211,6 +175,43 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
}
}
}
break
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
const newDoc = doc?.[tab.name]
const newPath = `${path ? `${path}.` : ''}${tab.name}`
if (typeof newDoc === 'object' && newDoc !== null) {
if (tab.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: tab.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: tab.fields,
path: newPath,
rows,
})
}
}
} else {
traverseFields({
doc,
fields: tab.fields,
path,
rows,
})
}
})
}
}
})

View File

@@ -27,30 +27,6 @@ type Args = {
export const traverseFields = (args: Args) => {
args.fields.forEach((field) => {
switch (field.type) {
case 'group': {
let newTableName = `${args.newTableName}_${toSnakeCase(field.name)}`
if (field.localized && args.payload.config.localization) {
newTableName += args.adapter.localesSuffix
}
return traverseFields({
...args,
columnPrefix: `${args.columnPrefix}${toSnakeCase(field.name)}_`,
fields: field.fields,
newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}`,
})
}
case 'row':
case 'collapsible': {
return traverseFields({
...args,
fields: field.fields,
})
}
case 'array': {
const newTableName = args.adapter.tableNameMap.get(
`${args.newTableName}_${toSnakeCase(field.name)}`,
@@ -82,7 +58,42 @@ export const traverseFields = (args: Args) => {
})
})
}
case 'collapsible':
case 'row': {
return traverseFields({
...args,
fields: field.fields,
})
}
case 'group': {
let newTableName = `${args.newTableName}_${toSnakeCase(field.name)}`
if (field.localized && args.payload.config.localization) {
newTableName += args.adapter.localesSuffix
}
return traverseFields({
...args,
columnPrefix: `${args.columnPrefix}${toSnakeCase(field.name)}_`,
fields: field.fields,
newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}`,
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
args.pathsToQuery.add(`${args.path ? `${args.path}.` : ''}${field.name}`)
}
}
return null
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
@@ -101,17 +112,6 @@ export const traverseFields = (args: Args) => {
})
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
args.pathsToQuery.add(`${args.path ? `${args.path}.` : ''}${field.name}`)
}
}
return null
}
}
})
}

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {
@@ -10,6 +10,13 @@
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {

View File

@@ -0,0 +1,42 @@
import type { CountGlobalVersions, SanitizedGlobalConfig } from 'payload'
import { buildVersionGlobalFields } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
this: DrizzleAdapter,
{ global, locale, req, where: whereArg },
) {
const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find(
({ slug }) => slug === global,
)
const tableName = this.tableNameMap.get(
`_${toSnakeCase(globalConfig.slug)}${this.versionsSuffix}`,
)
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const fields = buildVersionGlobalFields(this.payload.config, globalConfig)
const { joins, where } = buildQuery({
adapter: this,
fields,
locale,
tableName,
where: whereArg,
})
const countResult = await this.countDistinct({
db,
joins,
tableName,
where,
})
return { totalDocs: countResult }
}

View File

@@ -0,0 +1,40 @@
import type { CountVersions, SanitizedCollectionConfig } from 'payload'
import { buildVersionCollectionFields } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
export const countVersions: CountVersions = async function countVersions(
this: DrizzleAdapter,
{ collection, locale, req, where: whereArg },
) {
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
const tableName = this.tableNameMap.get(
`_${toSnakeCase(collectionConfig.slug)}${this.versionsSuffix}`,
)
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const fields = buildVersionCollectionFields(this.payload.config, collectionConfig)
const { joins, where } = buildQuery({
adapter: this,
fields,
locale,
tableName,
where: whereArg,
})
const countResult = await this.countDistinct({
db,
joins,
tableName,
where,
})
return { totalDocs: countResult }
}

View File

@@ -1,7 +1,6 @@
export { countDistinct } from '../postgres/countDistinct.js'
export { createDatabase } from '../postgres/createDatabase.js'
export { createExtensions } from '../postgres/createExtensions.js'
export { convertPathToJSONTraversal } from '../postgres/createJSONQuery/convertPathToJSONTraversal.js'
export { createJSONQuery } from '../postgres/createJSONQuery/index.js'
export { createMigration } from '../postgres/createMigration.js'
export { defaultDrizzleSnapshot } from '../postgres/defaultSnapshot.js'

View File

@@ -218,32 +218,6 @@ export const traverseFields = ({
break
}
case 'select': {
if (field.hasMany) {
if (select) {
if (
(selectMode === 'include' && !select[field.name]) ||
(selectMode === 'exclude' && select[field.name] === false)
) {
break
}
}
const withSelect: Result = {
columns: {
id: false,
order: false,
parent: false,
},
orderBy: ({ order }, { asc }) => [asc(order)],
}
currentArgs.with[`${path}${field.name}`] = withSelect
}
break
}
case 'blocks': {
const blocksSelect = selectAllOnCurrentLevel ? true : select?.[field.name]
@@ -356,6 +330,7 @@ export const traverseFields = ({
}
case 'group':
case 'tab': {
const fieldSelect = select?.[field.name]
@@ -389,47 +364,6 @@ export const traverseFields = ({
break
}
case 'point': {
if (adapter.name === 'sqlite') {
break
}
const args = field.localized ? _locales : currentArgs
if (!args.columns) {
args.columns = {}
}
if (!args.extras) {
args.extras = {}
}
const name = `${path}${field.name}`
// Drizzle handles that poorly. See https://github.com/drizzle-team/drizzle-orm/issues/2526
// Additionally, this way we format the column value straight in the database using ST_AsGeoJSON
args.columns[name] = false
let shouldSelect = false
if (select || selectAllOnCurrentLevel) {
if (
selectAllOnCurrentLevel ||
(selectMode === 'include' && select[field.name] === true) ||
(selectMode === 'exclude' && typeof select[field.name] === 'undefined')
) {
shouldSelect = true
}
} else {
shouldSelect = true
}
if (shouldSelect) {
args.extras[name] = sql.raw(`ST_AsGeoJSON(${toSnakeCase(name)})::jsonb`).as(name)
}
break
}
case 'join': {
// when `joinsQuery` is false, do not join
if (joinQuery === false) {
@@ -621,6 +555,72 @@ export const traverseFields = ({
break
}
case 'point': {
if (adapter.name === 'sqlite') {
break
}
const args = field.localized ? _locales : currentArgs
if (!args.columns) {
args.columns = {}
}
if (!args.extras) {
args.extras = {}
}
const name = `${path}${field.name}`
// Drizzle handles that poorly. See https://github.com/drizzle-team/drizzle-orm/issues/2526
// Additionally, this way we format the column value straight in the database using ST_AsGeoJSON
args.columns[name] = false
let shouldSelect = false
if (select || selectAllOnCurrentLevel) {
if (
selectAllOnCurrentLevel ||
(selectMode === 'include' && select[field.name] === true) ||
(selectMode === 'exclude' && typeof select[field.name] === 'undefined')
) {
shouldSelect = true
}
} else {
shouldSelect = true
}
if (shouldSelect) {
args.extras[name] = sql.raw(`ST_AsGeoJSON(${toSnakeCase(name)})::jsonb`).as(name)
}
break
}
case 'select': {
if (field.hasMany) {
if (select) {
if (
(selectMode === 'include' && !select[field.name]) ||
(selectMode === 'exclude' && select[field.name] === false)
) {
break
}
}
const withSelect: Result = {
columns: {
id: false,
order: false,
parent: false,
},
orderBy: ({ order }, { asc }) => [asc(order)],
}
currentArgs.with[`${path}${field.name}`] = withSelect
}
break
}
default: {
if (!select && !selectAllOnCurrentLevel) {
break

View File

@@ -1,4 +1,6 @@
export { count } from './count.js'
export { countGlobalVersions } from './countGlobalVersions.js'
export { countVersions } from './countVersions.js'
export { create } from './create.js'
export { createGlobal } from './createGlobal.js'
export { createGlobalVersion } from './createGlobalVersion.js'

View File

@@ -1,15 +0,0 @@
import { formatJSONPathSegment } from './formatJSONPathSegment.js'
export const convertPathToJSONTraversal = (incomingSegments: string[]) => {
const segments = [...incomingSegments]
segments.shift()
return segments.reduce((res, segment, i) => {
const formattedSegment = formatJSONPathSegment(segment)
if (i + 1 === segments.length) {
return `${res}->>${formattedSegment}`
}
return `${res}->${formattedSegment}`
}, '')
}

View File

@@ -1,3 +0,0 @@
export const formatJSONPathSegment = (segment: string) => {
return Number.isNaN(parseInt(segment)) ? `'${segment}'` : segment
}

View File

@@ -1,87 +1,43 @@
import { convertPathToJSONTraversal } from './convertPathToJSONTraversal.js'
import { formatJSONPathSegment } from './formatJSONPathSegment.js'
import type { CreateJSONQueryArgs } from '../../types.js'
const operatorMap = {
contains: '~*',
equals: '=',
like: '~*',
const operatorMap: Record<string, string> = {
contains: '~',
equals: '==',
in: 'in',
like: 'like_regex',
not_equals: '!=',
not_in: 'in',
}
type FromArrayArgs = {
isRoot?: true
operator: string
pathSegments: string[]
treatAsArray?: string[]
value: unknown
}
const fromArray = ({ isRoot, operator, pathSegments, treatAsArray, value }: FromArrayArgs) => {
const newPathSegments = pathSegments.slice(isRoot ? 1 : 2)
const alias = `${pathSegments[isRoot ? 0 : 1]}_alias_${newPathSegments.length}`
newPathSegments.unshift(alias)
const arrayElements = isRoot
? pathSegments[0]
: `${pathSegments[0]} -> ${formatJSONPathSegment(pathSegments[1])}`
return `EXISTS (
SELECT 1
FROM jsonb_array_elements(${arrayElements}) AS ${alias}
WHERE ${createJSONQuery({
operator,
pathSegments: newPathSegments,
treatAsArray,
value,
})}
)`
}
type CreateConstraintArgs = {
operator: string
pathSegments: string[]
treatAsArray?: string[]
value: unknown
}
const createConstraint = ({ operator, pathSegments, value }: CreateConstraintArgs): string => {
const jsonQuery = convertPathToJSONTraversal(pathSegments)
return `${pathSegments[0]}${jsonQuery} ${operatorMap[operator]} '${value}'`
}
type Args = {
operator: string
pathSegments: string[]
treatAsArray?: string[]
treatRootAsArray?: boolean
value: unknown
}
export const createJSONQuery = ({
operator,
pathSegments,
treatAsArray,
treatRootAsArray,
value,
}: Args): string => {
if (treatRootAsArray) {
return fromArray({
isRoot: true,
operator,
pathSegments,
treatAsArray,
value,
})
const sanitizeValue = (value: unknown, operator?: string) => {
if (typeof value === 'string') {
// ignore casing with like
return `"${operator === 'like' ? '(?i)' : ''}${value}"`
}
if (treatAsArray.includes(pathSegments[1])) {
return fromArray({
operator,
pathSegments,
treatAsArray,
value,
return value as string
}
export const createJSONQuery = ({ column, operator, pathSegments, value }: CreateJSONQueryArgs) => {
const columnName = typeof column === 'object' ? column.name : column
const jsonPaths = pathSegments
.slice(1)
.map((key) => {
return `${key}[*]`
})
.join('.')
let sql = ''
if (['in', 'not_in'].includes(operator) && Array.isArray(value)) {
value.forEach((item, i) => {
sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, value: item })}${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}`
})
} else if (operator === 'exists') {
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')`
} else {
sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
}
return createConstraint({ operator, pathSegments, treatAsArray, value })
return sql
}

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

@@ -182,197 +182,6 @@ export const traverseFields = ({
}
switch (field.type) {
case 'text': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyTextField = true
}
if (field.index) {
hasManyTextField = 'index'
} else if (!hasManyTextField) {
hasManyTextField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany text fields.',
)
}
} else {
targetTable[fieldName] = withDefault(varchar(columnName), field)
}
break
}
case 'email':
case 'code':
case 'textarea': {
targetTable[fieldName] = withDefault(varchar(columnName), field)
break
}
case 'number': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyNumberField = true
}
if (field.index) {
hasManyNumberField = 'index'
} else if (!hasManyNumberField) {
hasManyNumberField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany number fields.',
)
}
} else {
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'richText':
case 'json': {
targetTable[fieldName] = withDefault(jsonb(columnName), field)
break
}
case 'date': {
targetTable[fieldName] = withDefault(
timestamp(columnName, {
mode: 'string',
precision: 3,
withTimezone: true,
}),
field,
)
break
}
case 'point': {
targetTable[fieldName] = withDefault(geometryColumn(columnName), field)
if (!adapter.extensions.postgis) {
adapter.extensions.postgis = true
}
break
}
case 'radio':
case 'select': {
const enumName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `enum_${newTableName}_`,
target: 'enumName',
throwValidationError,
})
adapter.enums[enumName] = adapter.pgSchema.enum(
enumName,
field.options.map((option) => {
if (optionIsObject(option)) {
return option.value
}
return option
}) as [string, ...string[]],
)
if (field.type === 'select' && field.hasMany) {
const selectTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
throwValidationError,
versionsCustomName: versions,
})
const baseColumns: Record<string, PgColumnBuilder> = {
order: integer('order').notNull(),
parent: parentIDColumnMap[parentIDColType]('parent_id').notNull(),
value: adapter.enums[enumName]('value'),
}
const baseExtraConfig: BaseExtraConfig = {
orderIdx: (cols) => index(`${selectTableName}_order_idx`).on(cols.order),
parentFk: (cols) =>
foreignKey({
name: `${selectTableName}_parent_fk`,
columns: [cols.parent],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
parentIdx: (cols) => index(`${selectTableName}_parent_idx`).on(cols.parent),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns.locale = adapter.enums.enum__locales('locale').notNull()
baseExtraConfig.localeIdx = (cols) =>
index(`${selectTableName}_locale_idx`).on(cols.locale)
}
if (field.index) {
baseExtraConfig.value = (cols) => index(`${selectTableName}_value_idx`).on(cols.value)
}
buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull,
disableUnique,
fields: [],
rootTableName,
tableName: selectTableName,
versions,
})
relationsToBuild.set(fieldName, {
type: 'many',
// selects have their own localized table, independent of the base table.
localized: false,
target: selectTableName,
})
adapter.relations[`relations_${selectTableName}`] = relations(
adapter.tables[selectTableName],
({ one }) => ({
parent: one(adapter.tables[parentTableName], {
fields: [adapter.tables[selectTableName].parent],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}),
)
} else {
targetTable[fieldName] = withDefault(adapter.enums[enumName](columnName), field)
}
break
}
case 'checkbox': {
targetTable[fieldName] = withDefault(boolean(columnName), field)
break
}
case 'array': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
@@ -506,7 +315,6 @@ export const traverseFields = ({
break
}
case 'blocks': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
@@ -655,9 +463,88 @@ export const traverseFields = ({
break
}
case 'checkbox': {
targetTable[fieldName] = withDefault(boolean(columnName), field)
break
}
case 'code':
case 'tab':
case 'group': {
case 'email':
case 'textarea': {
targetTable[fieldName] = withDefault(varchar(columnName), field)
break
}
case 'collapsible':
case 'row': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: rowHasLocalizedField,
hasLocalizedManyNumberField: rowHasLocalizedManyNumberField,
hasLocalizedManyTextField: rowHasLocalizedManyTextField,
hasLocalizedRelationshipField: rowHasLocalizedRelationshipField,
hasManyNumberField: rowHasManyNumberField,
hasManyTextField: rowHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (rowHasLocalizedField) {
hasLocalizedField = true
}
if (rowHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (rowHasManyTextField) {
hasManyTextField = true
}
if (rowHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (rowHasManyNumberField) {
hasManyNumberField = true
}
if (rowHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'date': {
targetTable[fieldName] = withDefault(
timestamp(columnName, {
mode: 'string',
precision: 3,
withTimezone: true,
}),
field,
)
break
}
case 'group':
case 'tab': {
if (!('name' in field)) {
const {
hasLocalizedField: groupHasLocalizedField,
@@ -765,112 +652,143 @@ export const traverseFields = ({
break
}
case 'tabs': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
case 'json':
const {
hasLocalizedField: tabHasLocalizedField,
hasLocalizedManyNumberField: tabHasLocalizedManyNumberField,
hasLocalizedManyTextField: tabHasLocalizedManyTextField,
hasLocalizedRelationshipField: tabHasLocalizedRelationshipField,
hasManyNumberField: tabHasManyNumberField,
hasManyTextField: tabHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
case 'richText': {
targetTable[fieldName] = withDefault(jsonb(columnName), field)
break
}
if (tabHasLocalizedField) {
hasLocalizedField = true
}
if (tabHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (tabHasManyTextField) {
hasManyTextField = true
}
if (tabHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (tabHasManyNumberField) {
hasManyNumberField = true
}
if (tabHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
case 'number': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyNumberField = true
}
if (field.index) {
hasManyNumberField = 'index'
} else if (!hasManyNumberField) {
hasManyNumberField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany number fields.',
)
}
} else {
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'row':
case 'collapsible': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: rowHasLocalizedField,
hasLocalizedManyNumberField: rowHasLocalizedManyNumberField,
hasLocalizedManyTextField: rowHasLocalizedManyTextField,
hasLocalizedRelationshipField: rowHasLocalizedRelationshipField,
hasManyNumberField: rowHasManyNumberField,
hasManyTextField: rowHasManyTextField,
} = traverseFields({
case 'point': {
targetTable[fieldName] = withDefault(geometryColumn(columnName), field)
if (!adapter.extensions.postgis) {
adapter.extensions.postgis = true
}
break
}
case 'radio':
case 'select': {
const enumName = createTableName({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
config: field,
parentTableName: newTableName,
prefix: `enum_${newTableName}_`,
target: 'enumName',
throwValidationError,
})
if (rowHasLocalizedField) {
hasLocalizedField = true
}
if (rowHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (rowHasManyTextField) {
hasManyTextField = true
}
if (rowHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (rowHasManyNumberField) {
hasManyNumberField = true
}
if (rowHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
adapter.enums[enumName] = adapter.pgSchema.enum(
enumName,
field.options.map((option) => {
if (optionIsObject(option)) {
return option.value
}
return option
}) as [string, ...string[]],
)
if (field.type === 'select' && field.hasMany) {
const selectTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
throwValidationError,
versionsCustomName: versions,
})
const baseColumns: Record<string, PgColumnBuilder> = {
order: integer('order').notNull(),
parent: parentIDColumnMap[parentIDColType]('parent_id').notNull(),
value: adapter.enums[enumName]('value'),
}
const baseExtraConfig: BaseExtraConfig = {
orderIdx: (cols) => index(`${selectTableName}_order_idx`).on(cols.order),
parentFk: (cols) =>
foreignKey({
name: `${selectTableName}_parent_fk`,
columns: [cols.parent],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
parentIdx: (cols) => index(`${selectTableName}_parent_idx`).on(cols.parent),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns.locale = adapter.enums.enum__locales('locale').notNull()
baseExtraConfig.localeIdx = (cols) =>
index(`${selectTableName}_locale_idx`).on(cols.locale)
}
if (field.index) {
baseExtraConfig.value = (cols) => index(`${selectTableName}_value_idx`).on(cols.value)
}
buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull,
disableUnique,
fields: [],
rootTableName,
tableName: selectTableName,
versions,
})
relationsToBuild.set(fieldName, {
type: 'many',
// selects have their own localized table, independent of the base table.
localized: false,
target: selectTableName,
})
adapter.relations[`relations_${selectTableName}`] = relations(
adapter.tables[selectTableName],
({ one }) => ({
parent: one(adapter.tables[parentTableName], {
fields: [adapter.tables[selectTableName].parent],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}),
)
} else {
targetTable[fieldName] = withDefault(adapter.enums[enumName](columnName), field)
}
break
}
@@ -936,6 +854,88 @@ export const traverseFields = ({
break
case 'tabs': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: tabHasLocalizedField,
hasLocalizedManyNumberField: tabHasLocalizedManyNumberField,
hasLocalizedManyTextField: tabHasLocalizedManyTextField,
hasLocalizedRelationshipField: tabHasLocalizedRelationshipField,
hasManyNumberField: tabHasManyNumberField,
hasManyTextField: tabHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (tabHasLocalizedField) {
hasLocalizedField = true
}
if (tabHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (tabHasManyTextField) {
hasManyTextField = true
}
if (tabHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (tabHasManyNumberField) {
hasManyNumberField = true
}
if (tabHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'text': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyTextField = true
}
if (field.index) {
hasManyTextField = 'index'
} else if (!hasManyTextField) {
hasManyTextField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany text fields.',
)
}
} else {
targetTable[fieldName] = withDefault(varchar(columnName), field)
}
break
}
default:
break
}

View File

@@ -121,185 +121,6 @@ export const getTableColumnFromPath = ({
}
switch (field.type) {
case 'tabs': {
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix,
constraintPath,
constraints,
fields: field.tabs.map((tab) => ({
...tab,
type: 'tab',
})),
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix,
value,
})
}
case 'tab': {
if (tabHasName(field)) {
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix: `${columnPrefix}${field.name}_`,
constraintPath: `${constraintPath}${field.name}.`,
constraints,
fields: field.fields,
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value,
})
}
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix,
constraintPath,
constraints,
fields: field.fields,
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix,
value,
})
}
case 'group': {
if (locale && field.localized && adapter.payload.config.localization) {
newTableName = `${tableName}${adapter.localesSuffix}`
let condition = eq(adapter.tables[tableName].id, adapter.tables[newTableName]._parentID)
if (locale !== 'all') {
condition = and(condition, eq(adapter.tables[newTableName]._locale, locale))
}
addJoinTable({
condition,
joins,
table: adapter.tables[newTableName],
})
}
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix: `${columnPrefix}${field.name}_`,
constraintPath: `${constraintPath}${field.name}.`,
constraints,
fields: field.fields,
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value,
})
}
case 'select': {
if (field.hasMany) {
const newTableName = adapter.tableNameMap.get(
`${tableName}_${tableNameSuffix}${toSnakeCase(field.name)}`,
)
if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [
eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
eq(adapter.tables[newTableName]._locale, locale),
]
if (locale !== 'all') {
conditions.push(eq(adapter.tables[newTableName]._locale, locale))
}
addJoinTable({
condition: and(...conditions),
joins,
table: adapter.tables[newTableName],
})
} else {
addJoinTable({
condition: eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
joins,
table: adapter.tables[newTableName],
})
}
return {
columnName: 'value',
constraints,
field,
table: adapter.tables[newTableName],
}
}
break
}
case 'text':
case 'number': {
if (field.hasMany) {
let tableType = 'texts'
let columnName = 'text'
if (field.type === 'number') {
tableType = 'numbers'
columnName = 'number'
}
newTableName = `${rootTableName}_${tableType}`
const joinConstraints = [
eq(adapter.tables[rootTableName].id, adapter.tables[newTableName].parent),
like(adapter.tables[newTableName].path, `${constraintPath}${field.name}`),
]
if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [...joinConstraints]
if (locale !== 'all') {
conditions.push(eq(adapter.tables[newTableName]._locale, locale))
}
addJoinTable({
condition: and(...conditions),
joins,
table: adapter.tables[newTableName],
})
} else {
addJoinTable({
condition: and(...joinConstraints),
joins,
table: adapter.tables[newTableName],
})
}
return {
columnName,
constraints,
field,
table: adapter.tables[newTableName],
}
}
break
}
case 'array': {
newTableName = adapter.tableNameMap.get(
`${tableName}_${tableNameSuffix}${toSnakeCase(field.name)}`,
@@ -341,7 +162,6 @@ export const getTableColumnFromPath = ({
value,
})
}
case 'blocks': {
let blockTableColumn: TableColumn
let newTableName: string
@@ -447,7 +267,87 @@ export const getTableColumnFromPath = ({
break
}
case 'group': {
if (locale && field.localized && adapter.payload.config.localization) {
newTableName = `${tableName}${adapter.localesSuffix}`
let condition = eq(adapter.tables[tableName].id, adapter.tables[newTableName]._parentID)
if (locale !== 'all') {
condition = and(condition, eq(adapter.tables[newTableName]._locale, locale))
}
addJoinTable({
condition,
joins,
table: adapter.tables[newTableName],
})
}
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix: `${columnPrefix}${field.name}_`,
constraintPath: `${constraintPath}${field.name}.`,
constraints,
fields: field.fields,
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value,
})
}
case 'number':
case 'text': {
if (field.hasMany) {
let tableType = 'texts'
let columnName = 'text'
if (field.type === 'number') {
tableType = 'numbers'
columnName = 'number'
}
newTableName = `${rootTableName}_${tableType}`
const joinConstraints = [
eq(adapter.tables[rootTableName].id, adapter.tables[newTableName].parent),
like(adapter.tables[newTableName].path, `${constraintPath}${field.name}`),
]
if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [...joinConstraints]
if (locale !== 'all') {
conditions.push(eq(adapter.tables[newTableName]._locale, locale))
}
addJoinTable({
condition: and(...conditions),
joins,
table: adapter.tables[newTableName],
})
} else {
addJoinTable({
condition: and(...joinConstraints),
joins,
table: adapter.tables[newTableName],
})
}
return {
columnName,
constraints,
field,
table: adapter.tables[newTableName],
}
}
break
}
case 'relationship':
case 'upload': {
const newCollectionPath = pathSegments.slice(1).join('.')
if (Array.isArray(field.relationTo) || field.hasMany) {
@@ -692,6 +592,106 @@ export const getTableColumnFromPath = ({
break
}
case 'select': {
if (field.hasMany) {
const newTableName = adapter.tableNameMap.get(
`${tableName}_${tableNameSuffix}${toSnakeCase(field.name)}`,
)
if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [
eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
eq(adapter.tables[newTableName]._locale, locale),
]
if (locale !== 'all') {
conditions.push(eq(adapter.tables[newTableName]._locale, locale))
}
addJoinTable({
condition: and(...conditions),
joins,
table: adapter.tables[newTableName],
})
} else {
addJoinTable({
condition: eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
joins,
table: adapter.tables[newTableName],
})
}
return {
columnName: 'value',
constraints,
field,
table: adapter.tables[newTableName],
}
}
break
}
case 'tab': {
if (tabHasName(field)) {
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix: `${columnPrefix}${field.name}_`,
constraintPath: `${constraintPath}${field.name}.`,
constraints,
fields: field.fields,
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value,
})
}
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix,
constraintPath,
constraints,
fields: field.fields,
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix,
value,
})
}
case 'tabs': {
return getTableColumnFromPath({
adapter,
aliasTable,
collectionPath,
columnPrefix,
constraintPath,
constraints,
fields: field.tabs.map((tab) => ({
...tab,
type: 'tab',
})),
joins,
locale,
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
tableName: newTableName,
tableNameSuffix,
value,
})
}
default: {
// fall through
break

View File

@@ -103,6 +103,18 @@ export function parseParams({
Array.isArray(pathSegments) &&
pathSegments.length > 1
) {
if (adapter.name === 'postgres') {
const constraint = adapter.createJSONQuery({
column: rawColumn || table[columnName],
operator,
pathSegments,
value: val,
})
constraints.push(sql.raw(constraint))
break
}
const segments = pathSegments.slice(1)
segments.unshift(table[columnName].name)
@@ -142,11 +154,7 @@ export function parseParams({
if (adapter.name === 'sqlite' && operator === 'equals' && !isNaN(val)) {
formattedValue = val
} else if (['in', 'not_in'].includes(operator) && Array.isArray(val)) {
if (adapter.name === 'sqlite') {
formattedValue = `(${val.map((v) => `${v}`).join(',')})`
} else {
formattedValue = `(${val.map((v) => `'${v}'`).join(', ')})`
}
formattedValue = `(${val.map((v) => `${v}`).join(',')})`
} else {
formattedValue = `'${operatorKeys[operator].wildcard}${val}${operatorKeys[operator].wildcard}'`
}
@@ -287,6 +295,13 @@ export function parseParams({
if (field.type === 'point' && adapter.name === 'postgres') {
switch (operator) {
case 'intersects': {
constraints.push(
sql`ST_Intersects(${table[columnName]}, ST_GeomFromGeoJSON(${JSON.stringify(queryValue)}))`,
)
break
}
case 'near': {
const [lng, lat, maxDistance, minDistance] = queryValue as number[]
@@ -305,13 +320,6 @@ export function parseParams({
break
}
case 'intersects': {
constraints.push(
sql`ST_Intersects(${table[columnName]}, ST_GeomFromGeoJSON(${JSON.stringify(queryValue)}))`,
)
break
}
default:
break
}

View File

@@ -593,8 +593,16 @@ export const traverseFields = <T extends Record<string, unknown>>({
let val = fieldData
switch (field.type) {
case 'tab':
case 'group': {
case 'date': {
if (typeof fieldData === 'string') {
val = new Date(fieldData).toISOString()
}
break
}
case 'group':
case 'tab': {
const groupFieldPrefix = `${fieldPrefix || ''}${field.name}_`
const groupData = {}
const locale = table._locale as string
@@ -626,14 +634,6 @@ export const traverseFields = <T extends Record<string, unknown>>({
return
}
case 'text': {
if (typeof fieldData === 'string') {
val = String(fieldData)
}
break
}
case 'number': {
if (typeof fieldData === 'string') {
val = Number.parseFloat(fieldData)
@@ -642,15 +642,8 @@ export const traverseFields = <T extends Record<string, unknown>>({
break
}
case 'date': {
if (typeof fieldData === 'string') {
val = new Date(fieldData).toISOString()
}
break
}
case 'relationship':
case 'upload': {
if (
val &&
@@ -662,6 +655,13 @@ export const traverseFields = <T extends Record<string, unknown>>({
break
}
case 'text': {
if (typeof fieldData === 'string') {
val = String(fieldData)
}
break
}
default: {
break

View File

@@ -1,4 +1,5 @@
import type {
Column,
ColumnBaseConfig,
ColumnDataType,
DrizzleConfig,
@@ -148,6 +149,7 @@ export type Migration = {
} & MigrationData
export type CreateJSONQueryArgs = {
column?: Column | string
operator: string
pathSegments: string[]
table?: string
@@ -157,7 +159,7 @@ export type CreateJSONQueryArgs = {
}
export interface DrizzleAdapter extends BaseDatabaseAdapter {
convertPathToJSONTraversal: (incomingSegments: string[]) => string
convertPathToJSONTraversal?: (incomingSegments: string[]) => string
countDistinct: CountDistinct
createJSONQuery: (args: CreateJSONQueryArgs) => string
defaultDrizzleSnapshot: Record<string, unknown>

View File

@@ -391,8 +391,8 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
id,
errors: [
{
field: fieldName,
message: req.t('error:valueMustBeUnique'),
path: fieldName,
},
],
},

View File

@@ -1,22 +1,22 @@
MIT License
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
Portions Copyright (c) Meta Platforms, Inc. and affiliates.
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.0.0-beta.127",
"version": "3.0.0-beta.130",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
@@ -10,6 +10,13 @@
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {

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