Compare commits

..

33 Commits

Author SHA1 Message Date
Alessio Gravili
ccf701da50 fix(richtext-lexical): remove unused packages from package.json 2024-06-30 15:15:14 -04:00
Harley Salas
2285624632 feat(plugin-seo): russian translations (#6987) 2024-06-30 02:04:02 -04:00
Elliot DeNolf
ef21182eac chore(release): v3.0.0-beta.56 [skip ci] 2024-06-28 16:42:16 -04:00
Alessio Gravili
368dd2c167 feat(richtext-lexical): simplify schemaMap handling (#6980) 2024-06-28 16:35:51 -04:00
Alessio Gravili
8f346dfb62 feat!: show detailed validation errors in console (#6551)
BREAKING: `ValidationError` now requires the `global` or `collection`
slug, as well as an `errors` property. The actual errors are no longer
at the top-level.
2024-06-28 16:35:35 -04:00
Paul
559c0646fa fix(plugin-seo)!: data types plugin seo (#6979)
Changed the data to correctly match type generic being sent to the
generate functions. So now you can type your generateTitle etc.
functions like this

```ts
// before
const generateTitle: GenerateTitle = async <Page>({ doc, locale }) => {
  return `Website.com — ${doc?.title?.value}`
}


// curent
import type { GenerateDescription, GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
import type { Page } from './payload-types'

const generateTitle: GenerateTitle<Page> = async ({ doc, locale }) => {
  return `Website.com — ${doc?.title}`
}

const generateDescription: GenerateDescription<Page> = async ({ doc, locale }) => {
  return doc?.excerpt || 'generated description'
}

const generateURL: GenerateURL<Page> = async ({ doc, locale }) => {
  return `https://yoursite.com/${locale ? locale + '/' : ''}${doc?.slug || ''}`
}
```

Breaking change because it was previously a FormState value.
2024-06-28 12:58:36 -04:00
Alessio Gravili
75a3040029 feat(richtext-lexical): export SerializedHeadingNode, add default node types (#6978) 2024-06-28 15:34:04 +00:00
James Mikrut
2daefb2a81 chore: removes unused token arg to refresh operation (#6977)
## Description

Duplicate of #6976 for 3.x
2024-06-28 11:20:49 -04:00
Alessio Gravili
9cdcf20c95 feat(ui): expose CheckboxInpu, SelectInput and DatePicker (#6972) 2024-06-28 05:22:39 +00:00
James Mikrut
37e2da012b feat(next)!: allows auth strategies to return headers that need to be… (#6964)
## Description

Some authentication strategies may need to set headers for responses,
such as updating cookies via a refresh token, and similar. This PR
extends Payload's auth strategy capabilities with a manner of
accomplishing this.

This is a breaking change if you have custom authentication strategies
in Payload's 3.0 beta. But it's a simple one to update.

Instead of your custom auth strategy returning the `user`, now you must
return an object with a `user` property.

This is because you can now also optionally return `responseHeaders`,
which will be returned by Payload API responses if you define them in
your auth strategies. This can be helpful for cases where you need to
set cookies and similar, directly within your auth strategies.

Before: 

```ts
return user
```

After:

```ts
return { user }
```
2024-06-27 21:33:25 +00:00
James Mikrut
07f3f273cd feat: adds refresh hooks (#6965)
## Description

Adds collection `refresh` hooks to override the default `refresh`
operation behavior.
2024-06-27 21:22:01 +00:00
Alessio Gravili
0017c67f74 feat(richtext-lexical): new FieldsDrawer utility, improve blocks feature performance (#6967) 2024-06-27 16:36:08 -04:00
Alessio Gravili
0a42281de3 feat(richtext-lexical): new FieldsDrawer utility 2024-06-27 16:22:10 -04:00
Alessio Gravili
69a42fa428 fix(richtext-lexical): remove unnecessary JSON.parse(JSON.stringify()) of blocks feature formData 2024-06-27 16:21:36 -04:00
Frederic Perron
8c2779c02a Docs: Change reference to v2 PassportJS docs to utilize new custom strategies docs. (#6961)
## Description

<!-- Please include a summary of the pull request and any related issues
it fixes. Please also include relevant motivation and context. -->

The v3 documentation mislead people by using PassportJS even though it's
not in v3 and custom strategies should be used instead with the correct
link.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [x] Chore (non-breaking change which does not add functionality)
- [x] This change requires a documentation update

## Checklist:
- [x] Existing test suite passes locally with my changes
- [x] I have made corresponding changes to the documentation
2024-06-27 11:33:44 -04:00
Frederic Perron
06da53379a docs: wrong adapter name fixed (#6933)
This fixes the name of the adapters which were all using the _Vercel
Blob Storage_ in each of the S3, Azure and Google Cloud Storage adapters
demos.
2024-06-26 19:52:11 -04:00
Ritsu
4404a3c85c feat(ui): export SaveButton / SaveDraftButton components (#6952) 2024-06-26 22:58:31 +00:00
Alessio Gravili
11b53c2862 chore(templates): pin & upgrade typescript to 5.5.2, remove unnecessary dotenv (#6950) 2024-06-26 21:10:17 +00:00
Alessio Gravili
8e232e680e feat(richtext-lexical): upgradeLexicalData function (#6949)
In case of breaking lexical data changes, you can simply call
`upgradeLexicalData({ payload })` to upgrade every lexical field in your
payload field to the new data format.
2024-06-26 21:03:59 +00:00
Jacob Fletcher
70957b0d22 fix!: properly cases custom collection components (#6948)
## Description

Properties within the Custom Collection Components config were not
properly cased. In the Payload Config, there are places where we expose
_an array_ of Custom Components to render. These properties should be
cased in `camelCase` to indicate that its type is _**not**_ a component,
but rather, it's an _**array**_ of components. This is how all other
arrays are already cased throughout the config, therefore these
components break exiting convention. The `CapitalCase` convention is
reserved for _components themselves_, however, fixing this introduces a
breaking change. Here's how to migrate:

Old:

```ts
{
 // ...
 admin: {
   components: {
     AfterList: [],
     AfterListTable: [],
     BeforeList: [],
     BeforeListTable: [],
   }
  }
}
```

New:

```ts
{
 // ...
 admin: {
   components: {
     afterList: [],
     afterListTable: [],
     beforeList: [],
     beforeListTable: [],
   }
 }
}
```

The docs were also out of date for the Root-level Custom Components.
These components are documented in CaptalCase but are in fact cased
correctly in Payload. This PR fixes that.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] This change requires a documentation update

## Checklist:

- [x] Existing test suite passes locally with my changes
- [x] I have made corresponding changes to the documentation
2024-06-26 16:58:59 -04:00
Elliot DeNolf
4375a33706 chore(release): v3.0.0-beta.55 [skip ci] 2024-06-26 16:06:14 -04:00
Alessio Gravili
51056769e5 feat(richtext-lexical): new slate => lexical migration function which migrates all your documents at once (#6947) 2024-06-26 15:40:14 -04:00
Anders Semb Hermansen
abf6e9aa6b fix(richtext-lexical): properly set heading level translation for nb and pl (#6900) 2024-06-26 15:27:26 -04:00
James Mikrut
5ffc5a1248 fix: auth strategy exp (#6945)
## Description

Ensures that exp and auth strategy are available from the `me` and
`refresh` operations as well as passed through the `Auth` provider. Same
as #6943

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2024-06-26 14:42:20 -04:00
Jacob Fletcher
ed73dedd14 docs: improves plugins overview (#6944) 2024-06-26 14:31:24 -04:00
Jarrod Flesch
6b7ec6cbf2 feat: add the ability to pass in a response to upload handlers (#6926)
Adds the ability to set response headers by using a new
`uploads.modifyResponseHeaders` property. You could previously do this
in Express in Payload v2.

You can do this like so:

```ts
upload: {
  modifyResponseHeaders: ({ headers }) => {
    headers.set('Cache-Control', 'public, max-age=86400')
    return headers
  }
},
```
2024-06-26 13:39:52 -04:00
Jarrod Flesch
35eb16bbec feat: ability to pass uploadActions to the Upload component (#6941) 2024-06-26 13:20:54 -04:00
Jacob Fletcher
f47d6cb23c docs: accessing the config from custom components (#6942) 2024-06-26 12:46:48 -04:00
Jessica Chowdhury
c34aa86da1 fix: should not display create/login view with disableLocalStrategy: true (#6940)
## Description

The `createFirstUser` view should not be displayed or accessible when
`disableLocalStrategy: false`.

Issue reported in Discord
[here](https://discord.com/channels/967097582721572934/1215659716538273832/1255510914711687335).

- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [X] Existing test suite passes locally with my changes
2024-06-26 12:33:06 -04:00
Jacob Fletcher
ae8a5a9cb8 docs: automatic custom component detection (#6939) 2024-06-26 10:19:28 -04:00
Jarrod Flesch
d8d5a44895 feat: ability to add custom upload component (#6927) 2024-06-26 09:37:22 -04:00
Alessio Gravili
377a478fc2 docs(richtext-lexical): document remaining props for building custom features (#6930) 2024-06-25 19:01:50 -04:00
Alessio Gravili
0b2be54011 feat(richtext-lexical): improve lexical types (#6928) 2024-06-25 21:51:52 +00:00
188 changed files with 6002 additions and 4465 deletions

View File

@@ -12,7 +12,7 @@ All Custom Components in Payload are [React Server Components](https://react.dev
<Banner type="success">
<strong>Note:</strong>
Client Components continue to be fully supported. To use Client Components in your app, simply import them into a Server Component and render them. Ensure your Client Component includes the `use client` directive and that any [non-serializable props](https://react.dev/reference/rsc/use-client#serializable-types) are sanitized. [More details](#client-components).
Client Components continue to be fully supported. To use Client Components in your app, simply include the `use client` directive. Payload will automatically detect and remove all default [non-serializable props](https://react.dev/reference/rsc/use-client#serializable-types) before rendering your component. [More details](#client-components).
</Banner>
To swap in your own Custom Component, consult the list of available components below. Determine the scope that corresponds to what you are trying to accomplish, then [author your React component(s)](#building-custom-components) accordingly.
@@ -26,9 +26,9 @@ There are four main types of Custom Components in Payload:
## Custom Root Components
Root Components are those that effect the [Admin Panel](./overview) generally. You can override Root Components through the `admin.components` property of the [Payload Config](../getting-started/overview).
Root Components are those that effect the [Admin Panel](./overview) generally, such as the logo. You can override Root Components through the `admin.components` property of the [Payload Config](../getting-started/overview).
Here is an example showing what it might look like to swap out Root Components for your own Custom Components. See [Building Custom Components](#building-custom-components) for exact details on how to build them:
Here is an example showing what it might look like to swap out Root Components for your own. See [Building Custom Components](#building-custom-components) for exact details on how to build them:
```ts
import { buildConfig } from 'payload'
@@ -52,12 +52,12 @@ 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. |
| **`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. |
@@ -71,7 +71,7 @@ The following options are available:
### Custom Providers
You can add additional [React Context](https://react.dev/learn/scaling-up-with-reducer-and-context) to any Payload app through Custom Providers. As you add more and more Custom Components to your [Admin Panel](./overview), this is a great may to share state across all of them.
You can add additional [React Context](https://react.dev/learn/scaling-up-with-reducer-and-context) to any Payload app through Custom Providers. As you add more and more Custom Components to your [Admin Panel](./overview), this is a great way to share state across all of them.
To do this, add `admin.components.providers` to your config:
@@ -115,9 +115,9 @@ export const useMyCustomContext = () => useContext(MyCustomContext)
## Custom Collection Components
Collection Components are those that effect [Collection](../configuration/collections)-specific UI within the [Admin Panel](./overview). You can override Collection Components through the `admin.components` property on any [Collection Config](../configuration/collections).
Collection Components are those that effect [Collection](../configuration/collections)-specific UI within the [Admin Panel](./overview), such as the save button. You can override Collection Components through the `admin.components` property on any [Collection Config](../configuration/collections).
Here is an example showing what it might look like to swap out Collection Components for your own Custom Components. See [Building Custom Components](#building-custom-components) for exact details on how to build them:
Here is an example showing what it might look like to swap out Collection Components for your own. See [Building Custom Components](#building-custom-components) for exact details on how to build them:
```ts
import type { SanitizedCollectionConfig } from 'payload/types'
@@ -140,10 +140,10 @@ The following options are available:
| Path | Description |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **`BeforeList`** | Array of components to inject _before_ the built-in List view |
| **`BeforeListTable`** | Array of components to inject _before_ the built-in List view's table |
| **`AfterList`** | Array of components to inject _after_ the built-in List view |
| **`AfterListTable`** | Array of components to inject _after_ the built-in List view's table |
| **`beforeList`** | An array of components to inject _before_ the built-in List View |
| **`beforeListTable`** | An array of components to inject _before_ the built-in List View's table |
| **`afterList`** | An array of components to inject _after_ the built-in List View |
| **`afterListTable`** | An array of components to inject _after_ the built-in List View's table |
| **`edit.SaveButton`** | Replace the default `Save` button with a Custom Component. Drafts must be disabled |
| **`edit.SaveDraftButton`** | Replace the default `Save Draft` button with a Custom Component. Drafts must be enabled and autosave must be disabled. |
| **`edit.PublishButton`** | Replace the default `Publish` button with a Custom Component. Drafts must be enabled. |
@@ -152,9 +152,9 @@ The following options are available:
## Custom Global Components
Global Components are those that effect [Global](../configuration/globals)-specific UI within the [Admin Panel](./overview). You can override Global Components through the `admin.components` property on any [Global Config](../configuration/globals).
Global Components are those that effect [Global](../configuration/globals)-specific UI within the [Admin Panel](./overview), such as the save button. You can override Global Components through the `admin.components` property on any [Global Config](../configuration/globals).
Here is an example showing what it might look like to swap out Global Components for your own Custom Components. See [Building Custom Components](#building-custom-components) for exact details on how to build them:
Here is an example showing what it might look like to swap out Global Components for your own. See [Building Custom Components](#building-custom-components) for exact details on how to build them:
```ts
import type { SanitizedGlobalConfig } from 'payload/types'
@@ -187,7 +187,7 @@ The following options are available:
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) directly in the front-end.
To make building Custom Components as easy as possible, Payload automatically provides common props, such as the [`payload`](../local-api/overview) class, the [`i18n`](../configuration/i18n) object, etc. This means that when building Custom Components within the Admin Panel, you do not have to get these yourself like you would from an external application.
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 like you would from an external application.
Here is an example:
@@ -221,9 +221,9 @@ Custom Components also receive various other props that are specific to the cont
See [Root Components](#custom-root-components), [Collection Components](#custom-collection-components), [Global Components](#custom-global-components), or [Field Components](#custom-field-components) for a complete list of all available components.
</Banner>
#### Client Components
### 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 define your component in a new file with the `use client` directive at the top:
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.
```tsx
'use client' // highlight-line
@@ -240,29 +240,57 @@ export const MyClientComponent: React.FC = () => {
}
```
Then simply import and render your Client Component within your Server Component:
<Banner type="warning">
<strong>Reminder:</strong>
Client Components cannot be passed [non-serializable props](https://react.dev/reference/rsc/use-client#serializable-types). If you are rendering your Client Component _from within_ a Server Component, ensure that its props are serializable.
</Banner>
### Accessing the Payload Config
From any Server Component, the [Payload Config](../configuration/overview) can be retrieved using the `payload` prop:
```tsx
import React from 'react'
import { MyClientComponent } from './MyClientComponent'
export default function MyServerComponent() {
export default async function MyServerComponent({
payload: {
config // highlight-line
}
}) {
return (
<div>
<MyClientComponent />
</div>
<Link href={config.serverURL}>
Go Home
</Link>
)
}
```
<Banner type="warning">
<strong>Reminder:</strong>
Client Components cannot be passed [non-serializable props](https://react.dev/reference/rsc/use-client#serializable-types). Before rendering your Client Component from a Server Component, ensure that any props passed to it are appropriately sanitized.
But the Payload Config is [non-serializable](https://react.dev/reference/rsc/use-client#serializable-types) by design. It is full of custom validation functions, React components, etc. This means that the Payload Config, in its entirety, cannot be passed directly to Client Components.
For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](./hooks#useconfig) hook:
```tsx
import React from 'react'
import { useConfig } from '@payloadcms/ui'
export const MyClientComponent: React.FC = () => {
const { serverURL } = useConfig() // highlight-line
return (
<Link href={serverURL}>
Go Home
</Link>
)
}
```
<Banner type="success">
See [Using Hooks](#using-hooks) for more details.
</Banner>
#### Using Hooks
### Using Hooks
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](./hooks) on the client. For example, you might want to interact with one of Payload's many React Contexts:
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'
@@ -270,7 +298,7 @@ import React from 'react'
import { useDocumentInfo } from '@payloadcms/ui'
export const MyClientComponent: React.FC = () => {
const { slug } = useDocumentInfo()
const { slug } = useDocumentInfo() // highlight-line
return (
<p>{`Entity slug: ${slug}`}</p>
@@ -282,7 +310,7 @@ export const MyClientComponent: React.FC = () => {
See the [Hooks](./hooks) documentation for a full list of available hooks.
</Banner>
#### Getting the Current Language
### 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).
@@ -324,9 +352,9 @@ export const MyClientComponent: React.FC = () => {
See the [Hooks](./hooks) documentation for a full list of available hooks.
</Banner>
#### Getting the Current Locale
### Getting the Current Locale
All [Custom Views](./views) can support multiple locales to be consistent with Payload's [Localization](../configuration/localization). All Custom Views automatically receive the `locale` object as a prop by default. This can be used to scope API requests, etc.:
All [Custom Views](./views) can support multiple locales to be consistent with Payload's [Localization](../configuration/localization). They automatically receive the `locale` object as a prop by default. This can be used to scope API requests, etc.:
```tsx
import React from 'react'
@@ -368,7 +396,7 @@ const Greeting: React.FC = () => {
See the [Hooks](./hooks) documentation for a full list of available hooks.
</Banner>
#### Styling Custom Components
### Styling Custom Components
Payload has a robust [CSS Library](./customizing-css) that you can style your Custom Components similarly to Payload's built-in styling. This will ensure that your Custom Component matches the existing design system, and so that it automatically adapts to any theme changes.

View File

@@ -33,15 +33,15 @@ This key will automatically be made available to the client-side Payload bundle
'use client'
import React from 'react'
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY // highlight-line
const MyClientComponent = () => {
// do something with the key
return (
<p>
<div>
My Client Component
</p>
</div>
)
}
```
@@ -64,15 +64,15 @@ This key will be available to your Server Components as follows:
```tsx
import React from 'react'
const stripeSecret = process.env.STRIPE_SECRET
const stripeSecret = process.env.STRIPE_SECRET // highlight-line
const MyServerComponent = async () => {
// do something with the secret
return (
<p>
<div>
My Server Component
</p>
</div>
)
}
```

View File

@@ -780,7 +780,7 @@ const Greeting: React.FC = () => {
## useConfig
Used to easily fetch the Payload Client Config.
Used to easily retrieve the Payload [Client Config](./components#accessing-the-payload-config).
```tsx
'use client'

View File

@@ -53,7 +53,7 @@ app/
As shown above, all Payload routes are nested within the `(payload)` route group. This creates a boundary between the Admin Panel and the rest of your application by scoping all layouts and styles. The `layout.tsx` file within this directory, for example, is where Payload manages the `html` tag of the document to set proper `lang` and `dir` attributes, etc.
The `admin` directory contains all the _pages_ related to the interface itself, and the `api` and `graphql` directories contains all the _routes_ related to the [REST API](../rest-api/overview) and [GraphQL API](../graphql/overview). All admin routes are [easily configurable](#customizing-routes) to meet your application's requirements.
The `admin` directory contains all the _pages_ related to the interface itself, whereas the `api` and `graphql` directories contains all the _routes_ related to the [REST API](../rest-api/overview) and [GraphQL API](../graphql/overview). All admin routes are [easily configurable](#customizing-routes) to meet your application's requirements.
Finally, the `custom.scss` file is where you can add or override globally-oriented styles in the Admin Panel, such as the color palette. Customizing the look and feel through CSS alone is a powerful feature of the Admin Panel, [more on that here](./customizing-css).
@@ -128,9 +128,9 @@ You can use whatever Collection you'd like to access the Admin Panel as long as
- `admins` - meant to have a higher level of permissions to manage your data and access the Admin Panel
- `customers` - meant for end users of your app that should not be allowed to log into the Admin Panel
To do this, specify `admin: { user: 'admins' }` in your config. This will provide access to the Admin Panel to only `admins`. Any users authenticated as `customers` will be prevented from accessing the Admin Panel. See [Access Control](/docs/access-control/overview) for full details. For a complete, working example of role-based access control, check out the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/auth/payload).
To do this, specify `admin: { user: 'admins' }` in your config. This will provide access to the Admin Panel to only `admins`. Any users authenticated as `customers` will be prevented from accessing the Admin Panel. See [Access Control](/docs/access-control/overview) for full details.
#### Role-based access control
### Role-based Access Control
It is also possible to allow multiple user types into the Admin Panel with limited permissions. For example, you may wish to have two roles within the `admins` Collection:
@@ -141,11 +141,11 @@ To do this, add a `roles` or similar field to your auth-enabled Collection, then
## Customizing Routes
You have full control over the routes that Payload binds itself to. This includes both root-level routes such as the REST API, and admin-level routes such as the user's account page. You can customize these routes to meet the needs of your application simply by specifying the desired paths in your config.
You have full control over the routes that Payload binds itself to. This includes both [Root-level Routes](#root-level-routes) such as the [REST API](../rest-api/overview), and [Admin-level Routes](#admin-level-routes) such as the user's account page. You can customize these routes to meet the needs of your application simply by specifying the desired paths in your config.
#### Root-level Routes
### Root-level Routes
Root-level routes are those that are not behind the `/admin` path, such as the REST API and GraphQL APIs, or the root path of the Admin Panel itself.
Root-level routes are those that are not behind the `/admin` path, such as the [REST API](../rest-api/overview) and [GraphQL API](../graphql/overview), or the root path of the Admin Panel itself.
Here is an example of how you might modify root-level routes:
@@ -179,7 +179,7 @@ You can configure custom paths for the following root-level routes through the `
You can easily add _new_ routes to the Admin Panel through the `endpoints` property of the Payload Config. See [Custom Endpoints](../rest-api/overview#custom-endpoints) for more information.
</Banner>
#### Admin-level Routes
### Admin-level Routes
Admin-level routes are those behind the `/admin` path. These are the routes that are part of the Admin Panel itself, such as the user's account page, the login page, etc.

View File

@@ -6,7 +6,7 @@ desc:
keywords:
---
Views are the individual pages that make up the [Admin Panel](./overview), such as the Dashboard, List, and Edit views. One of the most powerful ways to customize the Admin Panel is to create Custom Views. These are [Custom Components](./components) that can either replace built-in ones or be entirely new.
Views are the individual pages that make up the [Admin Panel](./overview), such as the Dashboard, List, and Edit views. One of the most powerful ways to customize the Admin Panel is to create Custom Views. These are [Custom Components](./components) that can either replace built-in views or be entirely new.
To swap in your own Custom Views, consult the list of available components below. Determine the scope that corresponds to what you are trying to accomplish, then [author your React component(s)](#building-custom-views) accordingly.
@@ -57,7 +57,7 @@ For more granular control, pass a configuration object instead. Payload exposes
_\* An asterisk denotes that a property is required._
#### Adding New Views
### Adding New Views
To add a _new_ views to the [Admin Panel](./overview), simply add your own key to the `views` object with at least a `path` and `Component` property. For example:
@@ -209,7 +209,7 @@ The following options are available:
| **`API`** | The API view is used to display the REST API JSON response for a given document. |
| **`LivePreview`** | The LivePreview view is used to display the Live Preview interface. [More details](../live-preview). |
#### Document Tabs
### Document Tabs
Each Document View can be given a new tab in the Edit View, if desired. Tabs are highly configurable, from as simple as changing the label to swapping out the entire component, they can be modified in any way. To add or customize tabs in the Edit View, use the `Component.Tab` key:

View File

@@ -23,7 +23,7 @@ To enable Authentication on a collection, define an `auth` property and set it t
| **`forgotPassword`** | Customize the way that the `forgotPassword` operation functions. [More](/docs/authentication/config#forgot-password) |
| **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More](/docs/authentication/config#email-verification) |
| **`disableLocalStrategy`** | Advanced - disable Payload's built-in local auth strategy. Only use this property if you have replaced Payload's auth mechanisms with your own. |
| **`strategies`** | Advanced - an array of PassportJS authentication strategies to extend this collection's authentication with. [More](/docs/authentication/config#strategies) |
| **`strategies`** | Advanced - an array of custom authentification strategies to extend this collection's authentication with. [More](/docs/authentication/custom-strategies) |
### Forgot Password

View File

@@ -33,10 +33,12 @@ The `authenticate` function is passed the following arguments:
### Example Strategy
At its core a strategy simply takes information from the incoming request and returns a user. This is exactly how Payloads built-in strategies function.
At its core a strategy simply takes information from the incoming request and returns a user. This is exactly how Payload's built-in strategies function.
Your `authenticate` method should return an object containing a Payload user document and any optional headers that you'd like Payload to set for you when we return a response.
```ts
import { CollectionConfig } from 'payload/types'
import { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
@@ -59,7 +61,18 @@ export const Users: CollectionConfig = {
},
})
return usersQuery.docs[0] || null
return {
// Send the user back to authenticate,
// or send null if no user should be authenticated
user: usersQuery.docs[0] || null,
// Optionally, you can return headers
// that you'd like Payload to set here when
// it returns the response
responseHeaders: new Headers({
'some-header': 'my header value'
})
}
}
}
]

View File

@@ -191,7 +191,7 @@ mutation {
## Refresh
Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by sending the operation the token that is about to expire.
Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user.
This operation requires a non-expired token to send back a new one. If the user's token has already expired, you will need to allow them to log in again to retrieve a new token.
@@ -237,13 +237,6 @@ mutation {
}
```
<Banner type="success">
The Refresh operation will automatically find the user's token in either a JWT header or the
HTTP-only cookie. But, you can specify the token you're looking to refresh by providing the REST
API with a `token` within the JSON body of the request, or by providing the GraphQL resolver a
`token` arg.
</Banner>
## Verify by Email
If your collection supports email verification, the Verify operation will be exposed which accepts a verification token and sets the user's `_verified` property to `true`, thereby allowing the user to authenticate with the Payload API.

View File

@@ -28,7 +28,10 @@ Right now, Payload is officially supporting two rich text editors:
<Banner type="success">
<strong>
Consistent with Payload's goal of making you learn as little of Payload as possible, customizing
and using the Rich Text Editor does not involve learning how to develop for a <em>Payload</em>{' '}
and using the Rich Text Editor does not involve learning how to develop for a
{' '}
<em>Payload</em>
{' '}
rich text editor.
</strong>

View File

@@ -26,6 +26,8 @@ Additionally, `auth`-enabled collections feature the following hooks:
- [afterRefresh](#afterrefresh)
- [afterMe](#afterme)
- [afterForgotPassword](#afterforgotpassword)
- [refresh](#refresh)
- [me](#me)
## Config
@@ -59,6 +61,8 @@ export const ExampleHooks: CollectionConfig = {
afterRefresh: [(args) => {...}],
afterMe: [(args) => {...}],
afterForgotPassword: [(args) => {...}],
refresh: [(args) => {...}],
me: [(args) => {...}],
},
}
```
@@ -299,6 +303,32 @@ const afterForgotPasswordHook: CollectionAfterForgotPasswordHook = async ({
}) => {...}
```
### refresh
For auth-enabled Collections, this hook allows you to optionally replace the default behavior of the `refresh` operation with your own. If you optionally return a value from your hook, the operation will not perform its own logic and continue.
```ts
import type { CollectionRefreshHook } from 'payload'
const myRefreshHook: CollectionRefreshHook = async ({
args, // arguments passed into the `refresh` operation
user, // the user as queried from the database
}) => {...}
```
### me
For auth-enabled Collections, this hook allows you to optionally replace the default behavior of the `me` operation with your own. If you optionally return a value from your hook, the operation will not perform its own logic and continue.
```ts
import type { CollectionMeHook } from 'payload'
const meHook: CollectionMeHook = async ({
args, // arguments passed into the `me` operation
user, // the user as queried from the database
}) => {...}
```
## TypeScript
Payload exports a type for each Collection hook which can be accessed as follows:
@@ -319,5 +349,7 @@ import type {
CollectionAfterRefreshHook,
CollectionAfterMeHook,
CollectionAfterForgotPasswordHook,
CollectionRefreshHook,
CollectionMeHook,
} from 'payload'
```

View File

@@ -15,7 +15,7 @@ By convention, these are named feature.server.ts for server-side functionality a
## Server Feature
In order to get started with a new feature, you should start with the server feature which is the entry-point of your feature.
To start building new features, you should start with the server feature, which is the entry-point.
**Example myFeature/feature.server.ts:**
@@ -52,20 +52,16 @@ import { lexicalEditor } from '@payloadcms/richtext-lexical';
By default, this server feature does nothing - you haven't added any functionality yet. Depending on what you want your
feature to do, the ServerFeature type exposes various properties you can set to inject custom functionality into the lexical editor.
Here is an example:
### i18n
Each feature can register their own translations, which are automatically scoped to the feature key:
```ts
import { createServerFeature, createNode } from '@payloadcms/richtext-lexical';
import { MyClientFeature } from './feature.client.ts';
import { MyMarkdownTransformer } from './feature.client.ts';
import { createServerFeature } from '@payloadcms/richtext-lexical';
export const MyFeature = createServerFeature({
feature: {
// This allows you to connect the Client Feature. More on that below
ClientFeature: MyClientFeature,
// This allows you to add i18n translations scoped to your feature.
// This specific translation will be available under "lexical:myFeature:label" - myFeature
// being your feature key.
i18n: {
en: {
label: 'My Feature',
@@ -74,9 +70,75 @@ export const MyFeature = createServerFeature({
label: 'Mein Feature',
},
},
// Markdown Transformers in the server feature are used when converting the
// editor from or to markdown
},
key: 'myFeature',
})
```
This allows you to add i18n translations scoped to your feature. This specific example translation will be available under `lexical:myFeature:label` - `myFeature` being your feature key.
### Markdown Transformers
The Server Feature, just like the Client Feature, allows you to add markdown transformers. Markdown transformers on the server are used when [converting the editor from or to markdown](/docs/lexical/converters#markdown-lexical).
```ts
import { createServerFeature } from '@payloadcms/richtext-lexical';
import type { ElementTransformer } from '@lexical/markdown'
import {
$createMyNode,
$isMyNode,
MyNode
} from './nodes/MyNode'
const MyMarkdownTransformer: ElementTransformer = {
type: 'element',
dependencies: [MyNode],
export: (node, exportChildren) => {
if (!$isMyNode(node)) {
return null
}
return '+++'
},
// match ---
regExp: /^+++\s*$/,
replace: (parentNode) => {
const node = $createMyNode()
if (node) {
parentNode.replace(node)
}
},
}
export const MyFeature = createServerFeature({
feature: {
markdownTransformers: [MyMarkdownTransformer],
},
key: 'myFeature',
})
```
In this example, the node will be outputted as `+++` in Markdown, and the markdown `+++` will be converted to a `MyNode` node in the editor.
### Nodes
While nodes added to the server feature do not control how the node is rendered in the editor, they control other aspects of the node:
- HTML conversion
- Node Hooks
- Sub fields
- Behavior in a headless editor
The `createNode` helper function is used to create nodes with proper typing. It is recommended to use this function to create nodes.
```ts
import { createServerFeature, createNode } from '@payloadcms/richtext-lexical';
import {
MyNode
} from './nodes/MyNode'
export const MyFeature = createServerFeature({
feature: {
nodes: [
// Use the createNode helper function to more easily create nodes with proper typing
createNode({
@@ -99,6 +161,18 @@ export const MyFeature = createServerFeature({
})
```
While nodes in the client feature are added by themselves to the nodes array, nodes in the server feature can be added together with the following sibling options:
| Option | Description |
|---------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`getSubFields`** | If a node includes sub-fields (e.g. block and link nodes), passing the subFields schema here will make payload automatically populate & run hooks for them. |
| **`getSubFieldsData`** | If a node includes sub-fields, the sub-fields data needs to be returned here, alongside `getSubFields` which returns their schema. |
| **`graphQLPopulationPromises`** | Allows you to run population logic when a node's data was requested from GraphQL. While `getSubFields` and `getSubFieldsData` automatically handle populating sub-fields (since they run hooks on them), those are only populated in the Rest API. This is because the Rest API hooks do not have access to the 'depth' property provided by GraphQL. In order for them to be populated correctly in GraphQL, the population logic needs to be provided here. |
| **`node`** | The actual lexical node needs to be provided here. This also supports [lexical node replacements](https://lexical.dev/docs/concepts/node-replacement). |
| **`validations`** | This allows you to provide node validations, which are run when your document is being validated, alongside other payload fields. You can use it to throw a validation error for a specific node in case its data is incorrect. |
| **`converters`** | Allows you to define how a node can be serialized into different formats. Currently, only supports HTML. Markdown converters are defined in `markdownTransformers` and not here. |
| **`hooks`** | Just like payload fields, you can provide hooks which are run for this specific node. These are called Node Hooks. |
### Feature load order
Server features can also accept a function as the `feature` property (useful for sanitizing props, as mentioned below). This function will be called when the feature is loaded during the payload sanitization process:
@@ -211,6 +285,8 @@ export const MyClientFeature = createClientFeature({
})
```
This also supports [lexical node replacements](https://lexical.dev/docs/concepts/node-replacement).
**myFeature/nodes/MyNode.tsx:**
Here is a basic DecoratorNode example:
@@ -347,7 +423,7 @@ Please do not add any 'use client' directives to your nodes, as the node class c
### Plugins
One small part of a feature are plugins. The name stems from the lexical playground plugins and is just a small part of a lexical feature.
Plugins are simply react components which are added to the editor, within all the lexical context providers. They can be used to add any functionality
Plugins are simply React components which are added to the editor, within all the lexical context providers. They can be used to add any functionality
to the editor, by utilizing the lexical API.
Most commonly, they are used to register [lexical listeners](https://lexical.dev/docs/concepts/listeners), [node transforms](https://lexical.dev/docs/concepts/transforms) or [commands](https://lexical.dev/docs/concepts/commands).
@@ -430,19 +506,81 @@ export const MyNodePlugin: PluginComponent= () => {
}
```
In this example, we register a lexical command which simply inserts a new MyNode into the editor. This command can be called from anywhere within lexical, e.g. from within a custom node.
In this example, we register a lexical command, which simply inserts a new MyNode into the editor. This command can be called from anywhere within lexical, e.g. from within a custom node.
### Toolbar groups
Toolbar groups are visual containers which hold toolbar items. There are different toolbar group types which determine *how* a toolbar item is displayed: `dropdown` and `buttons`.
All the default toolbar groups are exported from `@payloadcms/richtext-lexical/client`. You can use them to add your own toolbar items to the editor:
- Dropdown: `toolbarAddDropdownGroupWithItems`
- Dropdown: `toolbarTextDropdownGroupWithItems`
- Buttons: `toolbarFormatGroupWithItems`
- Buttons: `toolbarFeatureButtonsGroupWithItems`
Within dropdown groups, items are positioned vertically when the dropdown is opened and include the icon & label. Within button groups, items are positioned horizontally and only include the icon. If a toolbar group with the same key is declared twice, all its items will be merged into one group.
#### Custom buttons toolbar group
| Option | Description |
|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`items`** | All toolbar items part of this toolbar group need to be added here. |
| **`key`** | Each toolbar group needs to have a unique key. Groups with the same keys will have their items merged together. |
| **`order`** | Determines where the toolbar group will be. |
| **`type`** | Controls the toolbar group type. Set to `buttons` to create a buttons toolbar group, which displays toolbar items horizontally using only their icons. |
Example:
```ts
import type { ToolbarGroup, ToolbarGroupItem } from '@payloadcms/richtext-lexical'
export const toolbarFormatGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => {
return {
type: 'buttons',
items,
key: 'myButtonsToolbar',
order: 10,
}
}
```
#### Custom dropdown toolbar group
| Option | Description |
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`items`** | All toolbar items part of this toolbar group need to be added here. |
| **`key`** | Each toolbar group needs to have a unique key. Groups with the same keys will have their items merged together. |
| **`order`** | Determines where the toolbar group will be. |
| **`type`** | Controls the toolbar group type. Set to `dropdown` to create a buttons toolbar group, which displays toolbar items vertically using their icons and labels, if the dropdown is open. |
| **`ChildComponent`** | The dropdown toolbar ChildComponent allows you to pass in a React Component which will be displayed within the dropdown button. |
Example:
```ts
import type { ToolbarGroup, ToolbarGroupItem } from '@payloadcms/richtext-lexical'
import { MyIcon } from './icons/MyIcon'
export const toolbarAddDropdownGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => {
return {
type: 'dropdown',
ChildComponent: MyIcon,
items,
key: 'myDropdownToolbar',
order: 10,
}
}
```
### Toolbar items
Custom nodes and features on its own are pointless, if they can not be added to the editor. You will need to hook in one of our interfaces which allow the user to interact with the editor:
Custom nodes and features on its own are pointless, if they can't be added to the editor. You will need to hook in one of our interfaces which allow the user to interact with the editor:
- Fixed toolbar which stays fixed at the top of the editor
- Inline, floating toolbar which appears when selecting text
- Slash menu which appears when typing `/` in the editor
- Markdown transformers which are triggered when a certain text pattern is typed in the editor
- Markdown transformers, which are triggered when a certain text pattern is typed in the editor
- Or any other interfaces which can be added via your own plugins. Our toolbars are a prime example of this - they are just plugins.
In order to add a toolbar item to either the floating or the inline toolbar, you can add a ToolbarGroup with a ToolbarItem to the `toolbarFixed` or `toolbarInline` props of your client feature:
To add a toolbar item to either the floating or the inline toolbar, you can add a ToolbarGroup with a ToolbarItem to the `toolbarFixed` or `toolbarInline` props of your client feature:
```ts
'use client'
@@ -481,10 +619,7 @@ export const MyClientFeature = createClientFeature({
})
```
You will have to provide a toolbar group first, and then the items for that toolbar group.
We already export all the default toolbar groups (like `toolbarAddDropdownGroupWithItems`, so you can use them as a base for your own toolbar items.
If a toolbar with the same key is declared twice, all its items will be merged together into one group.
You will have to provide a toolbar group first, and then the items for that toolbar group (more on that above).
A `ToolbarItem` various props you can use to customize its behavior:
@@ -492,7 +627,7 @@ A `ToolbarItem` various props you can use to customize its behavior:
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`ChildComponent`** | A React component which is rendered within your toolbar item's default button component. Usually, you want this to be an icon. |
| **`Component`** | A React component which is rendered in place of the toolbar item's default button component, thus completely replacing it. The `ChildComponent` and `onSelect` properties will be ignored. |
| **`label`** | The label will be displayed in your toolbar item, if it's within a dropdown group. In order to make use of i18n, this can be a function. |
| **`label`** | The label will be displayed in your toolbar item, if it's within a dropdown group. To make use of i18n, this can be a function. |
| **`key`** | Each toolbar item needs to have a unique key. |
| **`onSelect`** | A function which is called when the toolbar item is clicked. |
| **`isEnabled`** | This is optional and controls if the toolbar item is clickable or not. If `false` is returned here, it will be grayed out and unclickable. |
@@ -501,6 +636,34 @@ A `ToolbarItem` various props you can use to customize its behavior:
The API for adding an item to the floating inline toolbar (`toolbarInline`) is identical. If you wanted to add an item to both the fixed and inline toolbar, you can extract it into its own variable
(typed as `ToolbarGroup[]`) and add it to both the `toolbarFixed` and `toolbarInline` props.
### Slash Menu groups
We're exporting `slashMenuBasicGroupWithItems` from `@payloadcms/richtext-lexical/client` which you can use to add items to the slash menu labelled "Basic". If you want to create your own slash menu group, here is an example:
```ts
import type {
SlashMenuGroup,
SlashMenuItem,
} from '@payloadcms/richtext-lexical'
export function mwnSlashMenuGroupWithItems(items: SlashMenuItem[]): SlashMenuGroup {
return {
items,
key: 'myGroup',
label: 'My Group' // <= This can be a function to make use of i18n
}
}
```
By creating a helper function like this, you can easily re-use it and add items to it. All Slash Menu groups with the same keys will have their items merged together.
| Option | Description |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------|
| **`items`** | An array of `SlashMenuItem`'s which will be displayed in the slash menu. |
| **`label`** | The label will be displayed before your Slash Menu group. In order to make use of i18n, this can be a function. |
| **`key`** | Used for class names and, if label is not provided, for display. Slash menus with the same key will have their items merged together. |
### Slash Menu items
The API for adding items to the slash menu is similar. There are slash menu groups, and each slash menu groups has items. Here is an example:
@@ -533,17 +696,58 @@ export const MyClientFeature = createClientFeature({
})
```
| Option | Description |
|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`Icon`** | The icon which is rendered in your slash menu item. |
| **`label`** | The label will be displayed in your slash menu item. In order to make use of i18n, this can be a function. |
| **`key`** | Each slash menu item needs to have a unique key. The key will be matched when typing, displayed if no `label` property is set, and used for classNames. |
| **`onSelect`** | A function which is called when the slash menu item is selected. |
| **`keywords`** | Keywords are used in order to match the item for different texts typed after the '/'. E.g. you might want to show a horizontal rule item if you type both /hr, /separator, /horizontal etc. Additionally to the keywords, the label and key will be used to match the correct slash menu item. |
| Option | Description |
|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`Icon`** | The icon which is rendered in your slash menu item. |
| **`label`** | The label will be displayed in your slash menu item. In order to make use of i18n, this can be a function. |
| **`key`** | Each slash menu item needs to have a unique key. The key will be matched when typing, displayed if no `label` property is set, and used for classNames. |
| **`onSelect`** | A function which is called when the slash menu item is selected. |
| **`keywords`** | Keywords are used to match the item for different texts typed after the '/'. E.g. you might want to show a horizontal rule item if you type both /hr, /separator, /horizontal etc. In addition to the keywords, the label and key will be used to find the right slash menu item. |
### Markdown Transformers
The Client Feature, just like the Server Feature, allows you to add markdown transformers. Markdown transformers on the client are used to create new nodes when a certain markdown pattern is typed in the editor.
```ts
import { createClientFeature } from '@payloadcms/richtext-lexical/client';
import type { ElementTransformer } from '@lexical/markdown'
import {
$createMyNode,
$isMyNode,
MyNode
} from './nodes/MyNode'
const MyMarkdownTransformer: ElementTransformer = {
type: 'element',
dependencies: [MyNode],
export: (node, exportChildren) => {
if (!$isMyNode(node)) {
return null
}
return '+++'
},
// match ---
regExp: /^+++\s*$/,
replace: (parentNode) => {
const node = $createMyNode()
if (node) {
parentNode.replace(node)
}
},
}
export const MyFeature = createClientFeature({
markdownTransformers: [MyMarkdownTransformer],
})
```
In this example, a new `MyNode` will be inserted into the editor when `+++ ` is typed.
## Props
In order to accept props in your feature, you should first type them as a generic.
To accept props in your feature, type them as a generic.
Server Feature:
@@ -578,9 +782,9 @@ createServerFeature<UnSanitizedProps, SanitizedProps, UnSanitizedClientProps>({
})
```
Keep in mind that any sanitized props then have to returned in the `sanitizedServerFeatureProps` property.
Keep in mind that any sanitized props then have to be returned in the `sanitizedServerFeatureProps` property.
In the client feature, it works in a similar way:
In the client feature, it works similarly:
```ts
createClientFeature<UnSanitizedClientProps, SanitizedClientProps>(
@@ -617,4 +821,4 @@ The reason the client feature does not have the same props available as the serv
## More information
Take a look at the [features we've already built](https://github.com/payloadcms/payload/tree/beta/packages/richtext-lexical/src/features) - understanding how they work will help you understand how to create your own. There is no difference between the features included by default and the ones you create yourself - since those features are all isolated from the "core", you have access to the same APIs, whether the feature is part of payload or not!
Have a look at the [features we've already built](https://github.com/payloadcms/payload/tree/beta/packages/richtext-lexical/src/features) - understanding how they work will help you understand how to create your own. There is no difference between the features included by default and the ones you create yourself - since those features are all isolated from the "core", you have access to the same APIs, whether the feature is part of payload or not!

View File

@@ -46,35 +46,55 @@ const Pages: CollectionConfig = {
The `lexicalHTML()` function creates a new field that automatically converts the referenced lexical richText field into HTML through an afterRead hook.
### Generating HTML anywhere on the server:
### Generating HTML anywhere on the server
If you wish to convert JSON to HTML ad-hoc, use this code snippet:
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function:
```ts
import type { SerializedEditorState } from 'lexical'
import {
type SanitizedEditorConfig,
convertLexicalToHTML,
consolidateHTMLConverters,
} from '@payloadcms/richtext-lexical'
import { consolidateHTMLConverters, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
async function lexicalToHTML(
editorData: SerializedEditorState,
editorConfig: SanitizedEditorConfig,
) {
return await convertLexicalToHTML({
converters: consolidateHTMLConverters({ editorConfig }),
data: editorData,
payload, // if you have payload but no req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes)
req, // if you have req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes). No need to pass in payload if req is passed in.
})
}
await convertLexicalToHTML({
converters: consolidateHTMLConverters({ editorConfig }),
data: editorData,
payload, // if you have payload but no req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes)
req, // if you have req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes). No need to pass in payload if req is passed in.
})
```
This method employs `convertLexicalToHTML` from `@payloadcms/richtext-lexical`, which converts the serialized editor state into HTML.
Because every `Feature` is able to provide html converters, and because the `htmlFeature` can modify those or provide their own, we need to consolidate them with the default html Converters using the `consolidateHTMLConverters` function.
#### Example: Generating HTML within an afterRead hook
```ts
import type { FieldHook } from 'payload'
import {
HTMLConverterFeature,
consolidateHTMLConverters,
convertLexicalToHTML,
defaultEditorConfig,
defaultEditorFeatures,
sanitizeServerEditorConfig,
} from '@payloadcms/richtext-lexical'
const hook: FieldHook = async ({ req, siblingData }) => {
const editorConfig = defaultEditorConfig
editorConfig.features = [...defaultEditorFeatures, HTMLConverterFeature({})]
const sanitizedEditorConfig = await sanitizeServerEditorConfig(editorConfig, req.payload.config)
const html = await convertLexicalToHTML({
converters: consolidateHTMLConverters({ editorConfig: sanitizedEditorConfig }),
data: siblingData.lexicalSimple,
req,
})
return html
}
```
### CSS
Payload's lexical HTML converter does not generate CSS for you, but it does add classes to the generated HTML. You can use these classes to style the HTML in your frontend.
@@ -184,10 +204,11 @@ import { createHeadlessEditor } from '@lexical/headless' // <= make sure this pa
import { getEnabledNodes, sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
const yourEditorConfig // <= your editor config here
const payloadConfig // <= your payload config here
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig: sanitizeServerEditorConfig(yourEditorConfig),
editorConfig: sanitizeServerEditorConfig(yourEditorConfig, payloadConfig),
}),
})
```
@@ -316,7 +337,7 @@ Convert markdown content to the Lexical editor format with the following:
import { $convertFromMarkdownString } from '@lexical/markdown'
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig) // <= your editor config here
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & payload config here
const markdown = `# Hello World`
headlessEditor.update(
@@ -344,7 +365,7 @@ import { $convertToMarkdownString } from '@lexical/markdown'
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from 'lexical'
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig) // <= your editor config here
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & payload config here
const yourEditorState: SerializedEditorState // <= your current editor state here
// Import editor state into your headless editor

View File

@@ -10,6 +10,21 @@ keywords: lexical, rich text, editor, headless cms, migrate, migration
While both Slate and Lexical save the editor state in JSON, the structure of the JSON is different.
### Migration via Migration Script (Recommended)
Just import the `migrateSlateToLexical` function we provide, pass it the `payload` object and run it. Depending on the amount of collections, this might take a while.
IMPORTANT: This will overwrite all slate data. We recommend doing the following first:
1. Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support.
2. Make every richText field a lexical editor. This script will only convert lexical richText fields with old Slate data
3. Add the SlateToLexicalFeature (as seen below) first, and test it out by loading up the admin panel, to see if the migrator works as expected. You might have to build some custom converters for some fields first in order to convert custom Slate nodes. The SlateToLexicalFeature is where the converters are stored. Only fields with this feature added will be migrated.
```ts
import { migrateSlateToLexical } from '@payloadcms/richtext-lexical'
await migrateSlateToLexical({ payload })
```
### Migration via SlateToLexicalFeature
One way to handle this is to just give your lexical editor the ability to read the slate JSON.
@@ -42,75 +57,7 @@ This is by far the easiest way to migrate from Slate to Lexical, although it doe
- There is a performance hit when initializing the lexical editor
- The editor will still output the Slate data in the output JSON, as the on-the-fly converter only runs for the admin panel
The easy way to solve this: Just save the document! This overrides the slate data with the lexical data, and the next time the document is loaded, the lexical data will be used. This solves both the performance and the output issue for that specific document.
### Migration via migration script
The method described above does not solve the issue for all documents, though. If you want to convert all your documents to lexical, you can use a migration script. Here's a simple example:
```ts
import type { Payload, YourDocumentType } from 'payload'
import type { YourDocumentType } from 'payload/generated-types'
import {
cloneDeep,
convertSlateToLexical,
defaultSlateConverters,
} from '@payloadcms/richtext-lexical'
import { AnotherCustomConverter } from './lexicalFeatures/converters/AnotherCustomConverter'
export async function convertAll(payload: Payload, collectionName: string, fieldName: string) {
const docs: YourDocumentType[] = await payload.db.collections[collectionName].find({}).exec() // Use MongoDB models directly to query all documents at once
console.log(`Found ${docs.length} ${collectionName} docs`)
const converters = cloneDeep([...defaultSlateConverters, AnotherCustomConverter])
// Split docs into batches of 20.
const batchSize = 20
const batches = []
for (let i = 0; i < docs.length; i += batchSize) {
batches.push(docs.slice(i, i + batchSize))
}
let processed = 0 // Number of processed docs
for (const batch of batches) {
// Process each batch asynchronously
const promises = batch.map(async (doc: YourDocumentType) => {
const richText = doc[fieldName]
if (richText && Array.isArray(richText) && !('root' in richText)) {
// It's Slate data - skip already-converted data
const converted = convertSlateToLexical({
converters: converters,
slateData: richText,
})
await payload.update({
id: doc.id,
collection: collectionName as any,
depth: 0, // performance optimization. No need to run population.
data: {
[fieldName]: converted,
},
})
}
})
// Wait for all promises in the batch to complete. Resolving batches of 20 asynchronously is faster than waiting for each doc to update individually
await Promise.all(promises)
// Update the count of processed docs
processed += batch.length
console.log(`Converted ${processed} of ${docs.length}`)
}
}
```
The `convertSlateToLexical` is the same method used in the `SlateToLexicalFeature` - it handles traversing the Slate JSON for you.
Do note that this script might require adjustment depending on your document structure, especially if you have nested richText fields or localization enabled.
The easy way to solve this: Edit the richText field and save the document! This overrides the slate data with the lexical data, and the next time the document is loaded, the lexical data will be used. This solves both the performance and the output issue for that specific document. This, however, is a slow and gradual migration process, thus you will have to support both API formats. Especially for a large number of documents, we recommend running the migration script, as explained above.
### Converting custom Slate nodes
@@ -180,3 +127,19 @@ const Pages: CollectionConfig = {
Migrating from [payload-plugin-lexical](https://github.com/AlessioGr/payload-plugin-lexical) works similar to migrating from Slate.
Instead of a `SlateToLexicalFeature` there is a `LexicalPluginToLexicalFeature` you can use. And instead of `convertSlateToLexical` you can use `convertLexicalPluginToLexical`.
## Migrating lexical data from old version to new version
Each lexical node has a `version` property which is saved in the database. Every time we make a breaking change to the node's data, we increment the version. This way, we can detect an old version and automatically convert old data to the new format once you open up the editor.
The problem is, this migration only happens when you open the editor, modify the richText field (so that the field's `setValue` function is called) and save the document. Until you do that for all documents, some documents will still have the old data.
To solve this, we export an `upgradeLexicalData` function which goes through every single document in your payload app and re-saves it, if it has a lexical editor. This way, the data is automatically converted to the new format, and that automatic conversion gets applied to every single document in your app.
IMPORTANT: Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support.
```ts
import { upgradeLexicalData } from '@payloadcms/richtext-lexical'
await upgradeLexicalData({ payload })
```

View File

@@ -89,7 +89,7 @@ import { CallToAction } from '../blocks/CallToAction'
{
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
features: ({ defaultFeatures, rootFeatures }) => [
...defaultFeatures,
LinkFeature({
// Example showing how to customize the built-in fields
@@ -134,6 +134,15 @@ import { CallToAction } from '../blocks/CallToAction'
}
```
`features` can be both an array of features, or a function returning an array of features. The function provides the following props:
| Prop | Description |
|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`defaultFeatures`** | This opinionated array contains all "recommended" default features. You can see which features are included in the default features in the table below. |
| **`rootFeatures`** | This array contains all features that are enabled in the root richText editor (the one defined in the payload.config.ts). If this field is the root richText editor, or if the root richText editor is not a lexical editor, this array will be empty. |
## Features overview
Here's an overview of all the included features:
@@ -169,3 +178,120 @@ Notice how even the toolbars are features? That's how extensible our lexical edi
## Creating your own, custom Feature
You can find more information about creating your own feature in our [building custom feature docs](lexical/building-custom-features).
## TypeScript
Every single piece of saved data is 100% fully-typed within lexical. It provides a type for every single node, which can be imported from `@payloadcms/richtext-lexical` - each type is prefixed with `Serialized`, e.g. `SerializedUploadNode`.
In order to fully type the entire editor JSON, you can use our `TypedEditorState` helper type, which accepts a union of all possible node types as a generic. The reason we do not provide a type which already contains all possible node types is because the possible node types depend on which features you have enabled in your editor. Here is an example:
```ts
import type {
SerializedAutoLinkNode,
SerializedBlockNode,
SerializedHorizontalRuleNode,
SerializedLinkNode,
SerializedListItemNode,
SerializedListNode,
SerializedParagraphNode,
SerializedQuoteNode,
SerializedRelationshipNode,
SerializedTextNode,
SerializedUploadNode,
TypedEditorState,
SerializedHeadingNode,
} from '@payloadcms/richtext-lexical'
const editorState: TypedEditorState<
| SerializedAutoLinkNode
| SerializedBlockNode
| SerializedHorizontalRuleNode
| SerializedLinkNode
| SerializedListItemNode
| SerializedListNode
| SerializedParagraphNode
| SerializedQuoteNode
| SerializedRelationshipNode
| SerializedTextNode
| SerializedUploadNode
| SerializedHeadingNode
> = {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Some text. Every property here is fully-typed',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
textFormat: 0,
version: 1,
},
],
},
}
```
Alternatively, you can use the `DefaultTypedEditorState` type, which includes all types for all nodes included in the `defaultFeatures`:
```ts
import type {
DefaultTypedEditorState
} from '@payloadcms/richtext-lexical'
const editorState: DefaultTypedEditorState = {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Some text. Every property here is fully-typed',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
textFormat: 0,
version: 1,
},
],
},
}
```
Just like `TypedEditorState`, the `DefaultTypedEditorState` also accepts an optional node type union as a generic. Here, this would **add** the specified node types to the default ones. Example: `DefaultTypedEditorState<SerializedBlockNode | YourCustomSerializedNode>`.
This is a type-safe representation of the editor state. Looking at the auto-suggestions of `type` it will show you all the possible node types you can use.
Make sure to only use types exported from `@payloadcms/richtext-lexical`, not from the lexical core packages. We only have control over types we export and can guarantee that those are correct, even though lexical core may export types with identical names.
### Automatic type generation
Lexical does not generate the accurate type definitions for your richText fields for you yet - this will be improved in the future. Currently, it only outputs the rough shape of the editor JSON which you can enhance using type assertions.

View File

@@ -6,7 +6,7 @@ desc: Starting to build your own plugin? Find everything you need and learn best
keywords: plugins, template, config, configuration, extensions, custom, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
Building your own plugin is easy, and if you&apos;re already familiar with Payload then you&apos;ll have everything you need to get started. You can either start from scratch or use the Payload plugin template to get up and running quickly.
Building your own [Payload Plugin](./overview) is easy, and if you&apos;re already familiar with Payload then you&apos;ll have everything you need to get started. You can either start from scratch or use the [Plugin Template](#plugin-template) to get up and running quickly.
<Banner type="success">
To use the template, run `npx create-payload-app@latest -t plugin -n my-new-plugin` directly in
@@ -57,11 +57,11 @@ The initialization process goes in the following order:
## Plugin Template
In the [Payload plugin template](https://github.com/payloadcms/payload-plugin-template), you will see a common file structure that is used across plugins:
In the [Payload Plugin Template](https://github.com/payloadcms/payload-plugin-template), you will see a common file structure that is used across plugins:
1. root folder - general configuration
2. /src folder - everything related to the plugin
3. /dev folder - sanitized test project for development
1. `/` root folder - general configuration
2. `/src` folder - everything related to the plugin
3. `/dev` folder - sanitized test project for development
### The root folder
@@ -169,7 +169,7 @@ First up, the `src/index.ts` file - this is where the plugin should be imported
**Plugin.ts**
To reiterate, the essence of a payload plugin is simply to extend the Payload config - and that is exactly what we are doing in this file.
To reiterate, the essence of a [Payload Plugin](./overview) is simply to extend the [Payload Config](../configuration/overview) - and that is exactly what we are doing in this file.
```
export const samplePlugin =

View File

@@ -6,16 +6,18 @@ desc: Plugins provide a great way to modularize Payload functionalities into eas
keywords: plugins, config, configuration, extensions, custom, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
Payload comes with a built-in Plugins infrastructure that allows developers to build their own modular and easily reusable sets of functionality.
Payload Plugins take full advantage of the modularity of the [Payload Config](../configuration/overview), allowing developers to easily extend Payload's core functionality in a precise and granular way. This pattern allows developers to easily inject custom—sometimes complex—functionality into Payload apps from a very small touch-point.
There are many [Official Plugins](#official-plugins) available that solve for some of the most common uses cases, such as the [Form Builder Plugin](./seo) or [SEO Plugin](./seo). There are also [Community Plugins](#community-plugins) available, maintained entirely by contributing members. To extend Payload's functionality in some other way, you can easily [build your own plugin](./build-your-own).
Writing plugins is no more complex than writing regular JavaScript. If you know the basic concept of [callback functions](https://developer.mozilla.org/en-US/docs/Glossary/Callback_function) or how [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) works, and are up to speed with Payload concepts, then writing a plugin will be a breeze.
<Banner type="success">
Because we rely on a simple config-based structure, Payload plugins simply take in a user's
existing config and return a modified config with new fields, hooks, collections, admin views, or
Because we rely on a simple config-based structure, Payload Plugins simply take in an
existing config and returns a _modified_ config with new fields, hooks, collections, admin views, or
anything else you can think of.
</Banner>
Writing plugins is no more complex than writing regular JavaScript. If you know how [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) works and are up to speed with Payload concepts, writing a plugin will be a breeze.
**Example use cases:**
- Automatically sync data from a specific collection to HubSpot or a similar CRM when data is added or changes
@@ -27,37 +29,49 @@ Writing plugins is no more complex than writing regular JavaScript. If you know
- Integrate all `upload`-enabled collections with a third-party file host like S3 or Cloudinary
- Add custom endpoints or GraphQL queries / mutations with any type of custom functionality that you can think of
## How to install plugins
## Official Plugins
The base Payload config allows for a `plugins` property which takes an `array` of [`Plugins`](https://github.com/payloadcms/payload/blob/main/packages/payload/src/config/types.ts).
Payload maintains a set of Official Plugins that solve for some of the common use cases. These plugins are maintained by the Payload team and its contributors and are guaranteed to be stable and up-to-date.
```js
- [Form Builder](./form-builder)
- [Nested Docs](./nested-docs)
- [Redirects](./redirects)
- [Search](./search)
- [Sentry](./sentry)
- [SEO](./seo)
- [Stripe](./stripe)
You can also [build your own plugin](./build-your-own) to easily extend Payload's functionality in some other way. Once your plugin is ready, consider [sharing it with the community](#community-plugins).
Plugins are changing every day, so be sure to check back often to see what new plugins may have been added. If you have a specific plugin you would like to see, please feel free to start a new [Discussion](https://github.com/payloadcms/payload/discussions).
<Banner type="warning">
For a complete list of Official Plugins, visit the [Packages Directory](https://github.com/payloadcms/payload/tree/main/packages) of the [Payload Monorepo](https://github.com/payloadcms/payload).
</Banner>
## Community Plugins
Community Plugins are those that are maintained entirely by outside contributors. They are a great way to share your work across the ecosystem for others to use. You can discover Community Plugins by browsing the `payload-plugin` topic on [GitHub](https://github.com/topics/payload-plugin).
Some plugins have become so widely used that they are adopted as an [Official Plugin](#official-plugin), such as the [Lexical Plugin](https://github.com/AlessioGr/payload-plugin-lexical). If you have a plugin that you think should be an Official Plugin, please feel free to start a new [Discussion](https://github.com/payloadcms/payload/discussions).
<Banner type="warning">
For maintainers building plugins for others to use, please add the `payload-plugin` topic on [GitHub](https://github.com/topics/payload-plugin) to help others find it.
</Banner>
## Installing Plugins
The base [Payload Config](../configuration/overview) allows for a `plugins` property which takes an `array` of [Plugin Configs](./build-your-own).
```ts
import { buildConfig } from 'payload/config'
// note: these plugins are not real (yet?)
import addLastModified from 'payload-add-last-modified'
import passwordProtect from 'payload-password-protect'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { postgresAdapter } from '@payloadcms/db-postgres'
const config = buildConfig({
collections: [
{
slug: 'pages',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'content',
type: 'richText',
required: true,
},
],
},
],
db: mongooseAdapter({}) // or postgresAdapter({})
// ...
// highlight-start
plugins: [
// Many plugins require options to be passed.
// In the following example, we call the function
@@ -72,20 +86,15 @@ const config = buildConfig({
// To understand how to use the plugins you're interested in,
// consult their corresponding documentation
],
// highlight-end
})
export default config
```
### When Plugins are initialized
<Banner type="warning">
Payload Plugins are executed _after_ the incoming config is validated, but before it is sanitized and has had default options merged in. After all plugins are executed, the full config with all plugins will be sanitized.
</Banner>
Payload Plugins are executed _after_ the incoming config is validated, but before it is sanitized and had default options merged in.
After all plugins are executed, the full config with all plugins will be sanitized.
## Simple example
Here is an example for how to automatically add a `lastModifiedBy` field to all Payload collections using a Plugin written in TypeScript.
Here is an example what the `addLastModified` plugin from above might look like. It adds a `lastModifiedBy` field to all Payload collections. For full details, see [how to build your own plugin](./build-your-own).
```ts
import { Config, Plugin } from 'payload/config'
@@ -136,9 +145,3 @@ const addLastModified: Plugin = (incomingConfig: Config): Config => {
export default addLastModified
```
## Available Plugins
You can discover existing plugins by browsing the `payload-plugin` topic on [GitHub](https://github.com/topics/payload-plugin).
For maintainers building plugins for others to use, please add the topic to help others find it. If you would like one to be built by the core Payload team, [open a Feature Request](https://github.com/payloadcms/payload/discussions) in our GitHub Discussions board. We would be happy to review your code and maybe feature you and your plugin where appropriate.

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import type { Create, Document, PayloadRequestWithData } from 'payload'
import type { MongooseAdapter } from './index.js'
import handleError from './utilities/handleError.js'
import { handleError } from './utilities/handleError.js'
import { withSession } from './withSession.js'
export const create: Create = async function create(
@@ -15,7 +15,7 @@ export const create: Create = async function create(
try {
;[doc] = await Model.create([data], options)
} catch (error) {
handleError(error, req)
handleError({ collection, error, req })
}
// doc.toJSON does not do stuff like converting ObjectIds to string, or date strings to date objects. That's why we use JSON.parse/stringify here

View File

@@ -2,7 +2,7 @@ import type { PayloadRequestWithData, UpdateOne } from 'payload'
import type { MongooseAdapter } from './index.js'
import handleError from './utilities/handleError.js'
import { handleError } from './utilities/handleError.js'
import sanitizeInternalFields from './utilities/sanitizeInternalFields.js'
import { withSession } from './withSession.js'
@@ -29,7 +29,7 @@ export const updateOne: UpdateOne = async function updateOne(
try {
result = await Model.findOneAndUpdate(query, data, options)
} catch (error) {
handleError(error, req)
handleError({ collection, error, req })
}
result = JSON.parse(JSON.stringify(result))

View File

@@ -1,16 +1,30 @@
import httpStatus from 'http-status'
import { APIError, ValidationError } from 'payload'
const handleError = (error, req) => {
export const handleError = ({
collection,
error,
global,
req,
}: {
collection?: string
error
global?: string
req
}) => {
// Handle uniqueness error from MongoDB
if (error.code === 11000 && error.keyValue) {
throw new ValidationError(
[
{
field: Object.keys(error.keyValue)[0],
message: req.t('error:valueMustBeUnique'),
},
],
{
collection,
errors: [
{
field: Object.keys(error.keyValue)[0],
message: req.t('error:valueMustBeUnique'),
},
],
global,
},
req.t,
)
} else if (error.code === 11000) {
@@ -19,5 +33,3 @@ const handleError = (error, req) => {
throw error
}
}
export default handleError

View File

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

View File

@@ -313,12 +313,14 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
} catch (error) {
throw error.code === '23505'
? new ValidationError(
[
{
field: adapter.fieldConstraints[tableName][error.constraint],
message: req.t('error:valueMustBeUnique'),
},
],
{
errors: [
{
field: adapter.fieldConstraints[tableName][error.constraint],
message: req.t('error:valueMustBeUnique'),
},
],
},
req.t,
)
: error

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,20 @@
import type { Collection } from 'payload'
import { isolateObjectProperty, meOperation } from 'payload'
import { extractJWT, isolateObjectProperty, meOperation } from 'payload'
import type { Context } from '../types.js'
function meResolver(collection: Collection): any {
async function resolver(_, args, context: Context) {
const currentToken = extractJWT(context.req)
const options = {
collection,
currentToken,
depth: 0,
req: isolateObjectProperty(context.req, 'transactionID'),
}
const result = await meOperation(options)
if (collection.config.auth.removeTokenFromResponses) {

View File

@@ -1,24 +1,15 @@
import type { Collection } from 'payload'
import { extractJWT, generatePayloadCookie, isolateObjectProperty, refreshOperation } from 'payload'
import { generatePayloadCookie, isolateObjectProperty, refreshOperation } from 'payload'
import type { Context } from '../types.js'
function refreshResolver(collection: Collection): any {
async function resolver(_, args, context: Context) {
let token
token = extractJWT(context.req)
if (args.token) {
token = args.token
}
async function resolver(_, __, context: Context) {
const options = {
collection,
depth: 0,
req: isolateObjectProperty(context.req, 'transactionID'),
token,
}
const result = await refreshOperation(options)

View File

@@ -379,6 +379,9 @@ function initCollectionsGraphQL({ config, graphqlResult }: InitCollectionsGraphQ
exp: {
type: GraphQLInt,
},
strategy: {
type: GraphQLString,
},
token: {
type: GraphQLString,
},
@@ -405,14 +408,14 @@ function initCollectionsGraphQL({ config, graphqlResult }: InitCollectionsGraphQ
refreshedToken: {
type: GraphQLString,
},
strategy: {
type: GraphQLString,
},
user: {
type: collection.graphQL.JWT,
},
},
}),
args: {
token: { type: GraphQLString },
},
resolve: refresh(collection),
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { addDataAndFileToRequest } from '../../utilities/addDataAndFileToRequest
import { addLocalesToRequestFromData } from '../../utilities/addLocalesToRequest.js'
import { createPayloadRequest } from '../../utilities/createPayloadRequest.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
import { mergeHeaders } from '../../utilities/mergeHeaders.js'
const handleError = async (
payload: Payload,
@@ -122,7 +123,7 @@ export const POST =
return response
},
schema,
validationRules: (request, args, defaultRules) => defaultRules.concat(validationRules(args)),
validationRules: (_, args, defaultRules) => defaultRules.concat(validationRules(args)),
})(originalRequest)
const resHeaders = headersWithCors({
@@ -134,6 +135,10 @@ export const POST =
resHeaders.append(key, headers[key])
}
if (basePayloadRequest.responseHeaders) {
mergeHeaders(basePayloadRequest.responseHeaders, resHeaders)
}
return new Response(apiResponse.body, {
headers: resHeaders,
status: apiResponse.status,

View File

@@ -1,5 +1,5 @@
import httpStatus from 'http-status'
import { extractJWT, generatePayloadCookie, refreshOperation } from 'payload'
import { generatePayloadCookie, refreshOperation } from 'payload'
import type { CollectionRouteHandler } from '../types.js'
@@ -7,43 +7,31 @@ import { headersWithCors } from '../../../utilities/headersWithCors.js'
export const refresh: CollectionRouteHandler = async ({ collection, req }) => {
const { t } = req
const token = typeof req.data?.token === 'string' ? req.data.token : extractJWT(req)
const headers = headersWithCors({
headers: new Headers(),
req,
})
if (!token) {
return Response.json(
{
message: t('error:tokenNotProvided'),
},
{
headers,
status: httpStatus.UNAUTHORIZED,
},
)
}
const result = await refreshOperation({
collection,
req,
token,
})
const cookie = generatePayloadCookie({
collectionConfig: collection.config,
payload: req.payload,
token: result.refreshedToken,
})
if (result.setCookie) {
const cookie = generatePayloadCookie({
collectionConfig: collection.config,
payload: req.payload,
token: result.refreshedToken,
})
if (collection.config.auth.removeTokenFromResponses) {
delete result.refreshedToken
if (collection.config.auth.removeTokenFromResponses) {
delete result.refreshedToken
}
headers.set('Set-Cookie', cookie)
}
headers.set('Set-Cookie', cookie)
return Response.json(
{
message: t('authentication:tokenRefreshSuccessful'),

View File

@@ -35,10 +35,10 @@ export const getFile = async ({ collection, filename, req }: Args): Promise<Resp
if (accessResult instanceof Response) return accessResult
let response: Response = null
if (collection.config.upload.handlers?.length) {
let customResponse = null
for (const handler of collection.config.upload.handlers) {
response = await handler(req, {
customResponse = await handler(req, {
doc: accessResult,
params: {
collection: collection.config.slug,
@@ -47,21 +47,21 @@ export const getFile = async ({ collection, filename, req }: Args): Promise<Resp
})
}
if (response instanceof Response) return response
if (customResponse instanceof Response) return customResponse
}
const fileDir = collection.config.upload?.staticDir || collection.config.slug
const filePath = path.resolve(`${fileDir}/${filename}`)
const stats = await fsPromises.stat(filePath)
const data = streamFile(filePath)
const headers = new Headers(req.headers)
headers.set('Content-Length', stats.size + '')
const fileTypeResult = (await fileTypeFromFile(filePath)) || getFileTypeFallback(filePath)
let headers = new Headers()
headers.set('Content-Type', fileTypeResult.mime)
headers.set('Content-Length', stats.size + '')
headers = collection.config.upload?.modifyResponseHeaders
? collection.config.upload.modifyResponseHeaders({ headers })
: headers
return new Response(data, {
headers: headersWithCors({

View File

@@ -21,6 +21,7 @@ import { addDataAndFileToRequest } from '../../utilities/addDataAndFileToRequest
import { addLocalesToRequestFromData } from '../../utilities/addLocalesToRequest.js'
import { createPayloadRequest } from '../../utilities/createPayloadRequest.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
import { mergeHeaders } from '../../utilities/mergeHeaders.js'
import { access } from './auth/access.js'
import { forgotPassword } from './auth/forgotPassword.js'
import { init } from './auth/init.js'
@@ -122,7 +123,7 @@ const endpoints = {
},
}
const handleCustomEndpoints = ({
const handleCustomEndpoints = async ({
endpoints,
entitySlug,
payloadRequest,
@@ -130,7 +131,7 @@ const handleCustomEndpoints = ({
endpoints: Endpoint[] | GlobalConfig['endpoints']
entitySlug?: string
payloadRequest: PayloadRequest
}): Promise<Response> | Response => {
}): Promise<Response> => {
if (endpoints && endpoints.length > 0) {
let handlerParams = {}
const { pathname } = payloadRequest
@@ -170,7 +171,15 @@ const handleCustomEndpoints = ({
...payloadRequest.routeParams,
...handlerParams,
}
return customEndpoint.handler(payloadRequest)
const res = await customEndpoint.handler(payloadRequest)
if (res instanceof Response) {
if (payloadRequest.responseHeaders) {
mergeHeaders(payloadRequest.responseHeaders, res.headers)
}
return res
}
}
}
@@ -376,13 +385,20 @@ export const GET =
res = await endpoints.root.GET[slug1]({ req: payloadRequest })
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({
@@ -545,13 +561,20 @@ export const POST =
res = await endpoints.root.POST[slug1]({ req: payloadRequest })
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({
@@ -626,13 +649,20 @@ export const DELETE =
}
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({
@@ -708,13 +738,20 @@ export const PATCH =
}
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({

View File

@@ -97,11 +97,15 @@ export const createPayloadRequest = async ({
req.payloadDataLoader = getDataLoader(req)
req.user = await executeAuthStrategies({
const { responseHeaders, user } = await executeAuthStrategies({
headers: req.headers,
isGraphQL,
payload,
})
req.user = user
if (responseHeaders) req.responseHeaders = responseHeaders
return req
}

View File

@@ -0,0 +1,33 @@
const headersToJoin = ['set-cookie', 'warning', 'www-authenticate', 'proxy-authenticate', 'vary']
export function mergeHeaders(sourceHeaders: Headers, destinationHeaders: Headers): void {
// Create a map to store combined headers
const combinedHeaders = new Headers()
// Add existing destination headers to the combined map
destinationHeaders.forEach((value, key) => {
combinedHeaders.set(key, value)
})
// Add source headers to the combined map, joining specific headers
sourceHeaders.forEach((value, key) => {
const lowerKey = key.toLowerCase()
if (headersToJoin.includes(lowerKey)) {
if (combinedHeaders.has(key)) {
combinedHeaders.set(key, `${combinedHeaders.get(key)}, ${value}`)
} else {
combinedHeaders.set(key, value)
}
} else {
combinedHeaders.set(key, value)
}
})
// Clear the destination headers and set the combined headers
destinationHeaders.forEach((_, key) => {
destinationHeaders.delete(key)
})
combinedHeaders.forEach((value, key) => {
destinationHeaders.append(key, value)
})
}

View File

@@ -59,7 +59,7 @@ export const DefaultEditView: React.FC = () => {
const config = useConfig()
const router = useRouter()
const { dispatchFormQueryParams } = useFormQueryParams()
const { getFieldMap } = useComponentMap()
const { getComponentMap, getFieldMap } = useComponentMap()
const params = useSearchParams()
const depth = useEditDepth()
const { reportUpdate } = useDocumentEvents()
@@ -81,6 +81,10 @@ export const DefaultEditView: React.FC = () => {
const entitySlug = collectionConfig?.slug || globalConfig?.slug
const componentMap = getComponentMap({
collectionSlug: collectionConfig?.slug,
globalSlug: globalConfig?.slug,
})
const fieldMap = getFieldMap({
collectionSlug: collectionConfig?.slug,
globalSlug: globalConfig?.slug,
@@ -234,11 +238,15 @@ export const DefaultEditView: React.FC = () => {
)}
{upload && (
<React.Fragment>
<Upload
collectionSlug={collectionConfig.slug}
initialState={initialState}
uploadConfig={upload}
/>
{componentMap.Upload !== undefined ? (
componentMap.Upload
) : (
<Upload
collectionSlug={collectionConfig.slug}
initialState={initialState}
uploadConfig={upload}
/>
)}
</React.Fragment>
)}
</Fragment>

View File

@@ -73,7 +73,14 @@ export const RootPage = async ({
const routeWithAdmin = `${adminRoute}${createFirstUserRoute}`
if (!dbHasUser && currentRoute !== routeWithAdmin) {
const collectionConfig = config.collections.find(({ slug }) => slug === userSlug)
const disableLocalStrategy = collectionConfig?.auth?.disableLocalStrategy
if (disableLocalStrategy && currentRoute === routeWithAdmin) {
redirect(adminRoute)
}
if (!dbHasUser && currentRoute !== routeWithAdmin && !disableLocalStrategy) {
redirect(routeWithAdmin)
}

View File

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

View File

@@ -0,0 +1,3 @@
import type { CustomComponent } from '../../config/types.js'
export type CustomUpload = CustomComponent

View File

@@ -14,6 +14,7 @@ export type {
DocumentTabConfig,
DocumentTabProps,
} from './elements/Tab.js'
export type { CustomUpload } from './elements/Upload.js'
export type {
WithServerSidePropsComponent,
WithServerSidePropsComponentProps,

View File

@@ -1,14 +1,16 @@
import type { TypedUser } from '../index.js'
import type { AuthStrategyFunctionArgs } from './index.js'
import type { AuthStrategyFunctionArgs, AuthStrategyResult } from './index.js'
export const executeAuthStrategies = async (
args: AuthStrategyFunctionArgs,
): Promise<TypedUser | null> => {
return args.payload.authStrategies.reduce(async (accumulatorPromise, strategy) => {
const authUser = await accumulatorPromise
if (!authUser) {
return strategy.authenticate(args)
}
return authUser
}, Promise.resolve(null))
): Promise<AuthStrategyResult> => {
return args.payload.authStrategies.reduce(
async (accumulatorPromise, strategy) => {
const result: AuthStrategyResult = await accumulatorPromise
if (!result.user) {
return strategy.authenticate(args)
}
return result
},
Promise.resolve({ user: null }),
)
}

View File

@@ -15,6 +15,7 @@ export type AuthArgs = {
export type AuthResult = {
permissions: Permissions
responseHeaders?: Headers
user: TypedUser | null
}
@@ -26,12 +27,13 @@ export const auth = async (args: Required<AuthArgs>): Promise<AuthResult> => {
try {
const shouldCommit = await initTransaction(req)
const user = await executeAuthStrategies({
const { responseHeaders, user } = await executeAuthStrategies({
headers,
payload,
})
req.user = user
req.responseHeaders = responseHeaders
const permissions = await getAccessResults({
req,
@@ -41,6 +43,7 @@ export const auth = async (args: Required<AuthArgs>): Promise<AuthResult> => {
return {
permissions,
responseHeaders,
user,
}
} catch (error: unknown) {

View File

@@ -83,10 +83,16 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
const { email: unsanitizedEmail, password } = data
if (typeof unsanitizedEmail !== 'string' || unsanitizedEmail.trim() === '') {
throw new ValidationError([{ field: 'email', message: req.i18n.t('validation:required') }])
throw new ValidationError({
collection: collectionConfig.slug,
errors: [{ field: 'email', message: req.i18n.t('validation:required') }],
})
}
if (typeof password !== 'string' || password.trim() === '') {
throw new ValidationError([{ field: 'password', message: req.i18n.t('validation:required') }])
throw new ValidationError({
collection: collectionConfig.slug,
errors: [{ field: 'password', message: req.i18n.t('validation:required') }],
})
}
const email = unsanitizedEmail ? unsanitizedEmail.toLowerCase().trim() : null

View File

@@ -7,6 +7,7 @@ import type { ClientUser, User } from '../types.js'
export type MeOperationResult = {
collection?: string
exp?: number
strategy?: string
token?: string
user?: ClientUser
}
@@ -17,11 +18,9 @@ export type Arguments = {
req: PayloadRequestWithData
}
export const meOperation = async ({
collection,
currentToken,
req,
}: Arguments): Promise<MeOperationResult> => {
export const meOperation = async (args: Arguments): Promise<MeOperationResult> => {
const { collection, currentToken, req } = args
let result: MeOperationResult = {
user: null,
}
@@ -47,15 +46,32 @@ export const meOperation = async ({
delete user.collection
result = {
collection: req.user.collection,
user,
// /////////////////////////////////////
// me hook - Collection
// /////////////////////////////////////
for (const meHook of collection.config.hooks.me) {
const hookResult = await meHook({ args, user })
if (hookResult) {
result.user = hookResult.user
result.exp = hookResult.exp
break
}
}
if (currentToken) {
const decoded = jwt.decode(currentToken) as jwt.JwtPayload
if (decoded) result.exp = decoded.exp
result.token = currentToken
result.collection = req.user.collection
result.strategy = req.user._strategy
if (!result.user) {
result.user = user
if (currentToken) {
const decoded = jwt.decode(currentToken) as jwt.JwtPayload
if (decoded) result.exp = decoded.exp
if (!collection.config.auth.removeTokenFromResponses) result.token = currentToken
}
}
}

View File

@@ -14,13 +14,14 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
export type Result = {
exp: number
refreshedToken: string
setCookie?: boolean
strategy?: string
user: Document
}
export type Arguments = {
collection: Collection
req: PayloadRequestWithData
token: string
}
export const refreshOperation = async (incomingArgs: Arguments): Promise<Result> => {
@@ -61,7 +62,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
},
} = args
if (typeof args.token !== 'string' || !args.req.user) throw new Forbidden(args.req.t)
if (!args.req.user) throw new Forbidden(args.req.t)
const parsedURL = url.parse(args.req.url)
const isGraphQL = parsedURL.pathname === config.routes.graphQL
@@ -73,22 +74,41 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
req: args.req,
})
const fieldsToSign = getFieldsToSign({
collectionConfig,
email: user?.email as string,
user: args?.req?.user,
})
let result: Result
const refreshedToken = jwt.sign(fieldsToSign, secret, {
expiresIn: collectionConfig.auth.tokenExpiration,
})
// /////////////////////////////////////
// refresh hook - Collection
// /////////////////////////////////////
const exp = (jwt.decode(refreshedToken) as Record<string, unknown>).exp as number
for (const refreshHook of args.collection.config.hooks.refresh) {
const hookResult = await refreshHook({ args, user })
let result: Result = {
exp,
refreshedToken,
user,
if (hookResult) {
result = hookResult
break
}
}
if (!result) {
const fieldsToSign = getFieldsToSign({
collectionConfig,
email: user?.email as string,
user: args?.req?.user,
})
const refreshedToken = jwt.sign(fieldsToSign, secret, {
expiresIn: collectionConfig.auth.tokenExpiration,
})
const exp = (jwt.decode(refreshedToken) as Record<string, unknown>).exp as number
result = {
exp,
refreshedToken,
setCookie: true,
strategy: args.req.user._strategy,
user,
}
}
// /////////////////////////////////////
@@ -102,9 +122,9 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
(await hook({
collection: args.collection?.config,
context: args.req.context,
exp,
exp: result.exp,
req: args.req,
token: refreshedToken,
token: result.refreshedToken,
})) || result
}, Promise.resolve())

View File

@@ -67,7 +67,10 @@ export const resetPasswordOperation = async (args: Arguments): Promise<Result> =
if (!user) throw new APIError('Token is either invalid or has expired.', httpStatus.FORBIDDEN)
// TODO: replace this method
const { hash, salt } = await generatePasswordSaltHash({ password: data.password })
const { hash, salt } = await generatePasswordSaltHash({
collection: collectionConfig,
password: data.password,
})
user.salt = salt
user.hash = hash

View File

@@ -48,12 +48,14 @@ export const APIKeyAuthentication =
user.collection = collectionConfig.slug
user._strategy = 'api-key'
return user as User
return {
user: user as User,
}
}
} catch (err) {
return null
return { user: null }
}
}
return null
return { user: null }
}

View File

@@ -29,11 +29,13 @@ export const JWTAuthentication: AuthStrategyFunction = async ({
if (user && (!collection.config.auth.verify || user._verified)) {
user.collection = collection.config.slug
user._strategy = 'local-jwt'
return user as User
return {
user: user as User,
}
} else {
return null
return { user: null }
}
} catch (error) {
return null
return { user: null }
}
}

View File

@@ -1,5 +1,7 @@
import crypto from 'crypto'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import { ValidationError } from '../../../errors/index.js'
const defaultPasswordValidator = (password: string): string | true => {
@@ -24,16 +26,21 @@ function pbkdf2Promisified(password: string, salt: string): Promise<Buffer> {
}
type Args = {
collection: SanitizedCollectionConfig
password: string
}
export const generatePasswordSaltHash = async ({
collection,
password,
}: Args): Promise<{ hash: string; salt: string }> => {
const validationResult = defaultPasswordValidator(password)
if (typeof validationResult === 'string') {
throw new ValidationError([{ field: 'password', message: validationResult }])
throw new ValidationError({
collection: collection?.slug,
errors: [{ field: 'password', message: validationResult }],
})
}
const saltBuffer = await randomBytes()

View File

@@ -34,12 +34,13 @@ export const registerLocalStrategy = async ({
})
if (existingUser.docs.length > 0) {
throw new ValidationError([
{ field: 'email', message: req.t('error:userEmailAlreadyRegistered') },
])
throw new ValidationError({
collection: collection.slug,
errors: [{ field: 'email', message: req.t('error:userEmailAlreadyRegistered') }],
})
}
const { hash, salt } = await generatePasswordSaltHash({ password })
const { hash, salt } = await generatePasswordSaltHash({ collection, password })
const sanitizedDoc = { ...doc }
if (sanitizedDoc.password) delete sanitizedDoc.password

View File

@@ -101,9 +101,15 @@ export type AuthStrategyFunctionArgs = {
isGraphQL?: boolean
payload: Payload
}
export type AuthStrategyResult = {
responseHeaders?: Headers
user: User | null
}
export type AuthStrategyFunction = (
args: AuthStrategyFunctionArgs,
) => Promise<User | null> | User | null
) => AuthStrategyResult | Promise<AuthStrategyResult>
export type AuthStrategy = {
authenticate: AuthStrategyFunction
name: string

View File

@@ -39,6 +39,8 @@ export const defaults = {
beforeOperation: [],
beforeRead: [],
beforeValidate: [],
me: [],
refresh: [],
},
timestamps: true,
upload: false,

View File

@@ -8,11 +8,6 @@ import {
} from '../../config/shared/componentSchema.js'
import { openGraphSchema } from '../../config/shared/openGraphSchema.js'
const strategyBaseSchema = joi.object().keys({
logout: joi.boolean(),
refresh: joi.boolean(),
})
const collectionSchema = joi.object().keys({
slug: joi.string().required(),
access: joi.object({
@@ -26,16 +21,17 @@ const collectionSchema = joi.object().keys({
}),
admin: joi.object({
components: joi.object({
AfterList: joi.array().items(componentSchema),
AfterListTable: joi.array().items(componentSchema),
BeforeList: joi.array().items(componentSchema),
BeforeListTable: joi.array().items(componentSchema),
afterList: joi.array().items(componentSchema),
afterListTable: joi.array().items(componentSchema),
beforeList: joi.array().items(componentSchema),
beforeListTable: joi.array().items(componentSchema),
edit: joi.object({
Description: componentSchema,
PreviewButton: componentSchema,
PublishButton: componentSchema,
SaveButton: componentSchema,
SaveDraftButton: componentSchema,
Upload: componentSchema,
}),
views: joi.object({
Edit: joi.alternatives().try(
@@ -145,6 +141,8 @@ const collectionSchema = joi.object().keys({
beforeOperation: joi.array().items(joi.func()),
beforeRead: joi.array().items(joi.func()),
beforeValidate: joi.array().items(joi.func()),
me: joi.array().items(joi.func()),
refresh: joi.array().items(joi.func()),
}),
labels: joi.object({
plural: joi
@@ -184,6 +182,7 @@ const collectionSchema = joi.object().keys({
.unknown(),
),
mimeTypes: joi.array().items(joi.string()),
modifyResponseHeaders: joi.func(),
resizeOptions: joi
.object()
.keys({

View File

@@ -6,7 +6,13 @@ import type {
CustomPublishButton,
CustomSaveButton,
CustomSaveDraftButton,
CustomUpload,
} from '../../admin/types.js'
import type { Arguments as MeArguments } from '../../auth/operations/me.js'
import type {
Arguments as RefreshArguments,
Result as RefreshResult,
} from '../../auth/operations/refresh.js'
import type { Auth, ClientUser, IncomingAuthType } from '../../auth/types.js'
import type {
Access,
@@ -203,6 +209,16 @@ export type AfterMeHook<T extends TypeWithID = any> = (args: {
response: unknown
}) => any
export type RefreshHook<T extends TypeWithID = any> = (args: {
args: RefreshArguments
user: T
}) => Promise<RefreshResult | void> | (RefreshResult | void)
export type MeHook<T extends TypeWithID = any> = (args: {
args: MeArguments
user: T
}) => ({ exp: number; user: T } | void) | Promise<{ exp: number; user: T } | void>
export type AfterRefreshHook<T extends TypeWithID = any> = (args: {
/** The collection which this hook is being run on */
collection: SanitizedCollectionConfig
@@ -224,10 +240,10 @@ export type CollectionAdminOptions = {
* Custom admin components
*/
components?: {
AfterList?: CustomComponent[]
AfterListTable?: CustomComponent[]
BeforeList?: CustomComponent[]
BeforeListTable?: CustomComponent[]
afterList?: CustomComponent[]
afterListTable?: CustomComponent[]
beforeList?: CustomComponent[]
beforeListTable?: CustomComponent[]
/**
* Components within the edit view
*/
@@ -254,6 +270,11 @@ export type CollectionAdminOptions = {
* + autosave must be disabled
*/
SaveDraftButton?: CustomSaveDraftButton
/**
* Replaces the "Upload" section
* + upload must be enabled
*/
Upload?: CustomUpload
}
views?: {
/**
@@ -392,6 +413,19 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
beforeOperation?: BeforeOperationHook[]
beforeRead?: BeforeReadHook[]
beforeValidate?: BeforeValidateHook[]
/**
/**
* Use the `me` hook to control the `me` operation.
* Here, you can optionally instruct the me operation to return early,
* and skip its default logic.
*/
me?: MeHook[]
/**
* Use the `refresh` hook to control the refresh operation.
* Here, you can optionally instruct the refresh operation to return early,
* and skip its default logic.
*/
refresh?: RefreshHook[]
}
/**
* Label configuration

View File

@@ -260,7 +260,10 @@ export const updateByIDOperation = async <TSlug extends CollectionSlug>(
const dataToUpdate: Record<string, unknown> = { ...result }
if (shouldSavePassword && typeof password === 'string') {
const { hash, salt } = await generatePasswordSaltHash({ password })
const { hash, salt } = await generatePasswordSaltHash({
collection: collectionConfig,
password,
})
dataToUpdate.salt = salt
dataToUpdate.hash = hash
delete dataToUpdate.password

View File

@@ -11,7 +11,10 @@ class ExtendableError<TData extends object = { [key: string]: unknown }> extends
status: number
constructor(message: string, status: number, data: TData, isPublic: boolean) {
super(message)
super(message, {
// show data in cause
cause: data,
})
this.name = this.constructor.name
this.message = message
this.status = status

View File

@@ -5,14 +5,25 @@ import httpStatus from 'http-status'
import { APIError } from './APIError.js'
export class ValidationError extends APIError<{ field: string; message: string }[]> {
constructor(results: { field: string; message: string }[], t?: TFunction) {
export class ValidationError extends APIError<{
collection?: string
errors: { field: string; message: string }[]
global?: string
}> {
constructor(
results: { collection?: string; errors: { field: string; message: string }[]; global?: string },
t?: TFunction,
) {
const message = t
? t('error:followingFieldsInvalid', { count: results.length })
: results.length === 1
? t('error:followingFieldsInvalid', { count: results.errors.length })
: results.errors.length === 1
? en.translations.error.followingFieldsInvalid_one
: en.translations.error.followingFieldsInvalid_other
super(`${message} ${results.map((f) => f.field).join(', ')}`, httpStatus.BAD_REQUEST, results)
super(
`${message} ${results.errors.map((f) => f.field).join(', ')}`,
httpStatus.BAD_REQUEST,
results,
)
}
}

View File

@@ -69,7 +69,14 @@ export const beforeChange = async <T extends Record<string, unknown>>({
})
if (errors.length > 0) {
throw new ValidationError(errors, req.t)
throw new ValidationError(
{
collection: collection?.slug,
errors,
global: global?.slug,
},
req.t,
)
}
await mergeLocaleActions.reduce(async (priorAction, action) => {

View File

@@ -674,6 +674,8 @@ export type {
Collection,
CollectionConfig,
DataFromCollectionSlug,
MeHook as CollectionMeHook,
RefreshHook as CollectionRefreshHook,
RequiredDataFromCollection,
RequiredDataFromCollectionSlug,
SanitizedCollectionConfig,

View File

@@ -31,6 +31,8 @@ export type CustomPayloadRequestProperties = {
payloadUploadSizes?: Record<string, Buffer>
/** Query params on the request */
query: Record<string, unknown>
/** Any response headers that are required to be set when a response is sent */
responseHeaders?: Headers
/** The route parameters
* @example
* /:collection/:id -> /posts/123

View File

@@ -124,8 +124,11 @@ export type UploadConfig = {
*/
handlers?: ((
req: PayloadRequestWithData,
args: { doc: TypeWithID; params: { collection: string; filename: string } },
) => Promise<Response> | Response | null)[]
args: {
doc: TypeWithID
params: { collection: string; filename: string }
},
) => Promise<Response> | Promise<void> | Response | void)[]
imageSizes?: ImageSize[]
/**
* Restrict mimeTypes in the file picker. Array of valid mime types or mimetype wildcards
@@ -133,6 +136,11 @@ export type UploadConfig = {
* @default undefined
*/
mimeTypes?: string[]
/**
* Ability to modify the response headers fetching a file.
* @default undefined
*/
modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers
/**
* Sharp resize options for the original image.
* @link https://sharp.pixelplumbing.com/api-resize#resize

View File

@@ -7,7 +7,7 @@ const pinoPretty = (pinoPrettyImport.default ||
export type PayloadLogger = pinoImport.default.Logger
const prettyOptions = {
const prettyOptions: pinoPrettyImport.PrettyOptions = {
colorize: true,
ignore: 'pid,hostname',
translateTime: 'SYS:HH:MM:ss',

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -41,7 +41,7 @@ export type GenerateURL = (args: {
export type StaticHandler = (
req: PayloadRequestWithData,
args: { params: { collection: string; filename: string } },
) => Promise<Response> | Response
) => Promise<Response> | Promise<void> | Response | void
export interface PayloadCloudEmailOptions {
apiKey: string

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -5,10 +5,10 @@ import type { FieldType, FormFieldBase, Options } from '@payloadcms/ui'
import {
FieldLabel,
TextareaInput,
useAllFormFields,
useDocumentInfo,
useField,
useFieldProps,
useForm,
useLocale,
useTranslation,
} from '@payloadcms/ui'
@@ -35,7 +35,7 @@ export const MetaDescription: React.FC<MetaDescriptionProps> = (props) => {
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
const locale = useLocale()
const [fields] = useAllFormFields()
const { getData } = useForm()
const docInfo = useDocumentInfo()
const field: FieldType<string> = useField({
@@ -50,7 +50,7 @@ export const MetaDescription: React.FC<MetaDescriptionProps> = (props) => {
const genDescriptionResponse = await fetch('/api/plugin-seo/generate-description', {
body: JSON.stringify({
...docInfo,
doc: { ...fields },
doc: { ...getData() },
locale: typeof locale === 'object' ? locale?.code : locale,
} satisfies Parameters<GenerateDescription>[0]),
credentials: 'include',
@@ -63,7 +63,7 @@ export const MetaDescription: React.FC<MetaDescriptionProps> = (props) => {
const { result: generatedDescription } = await genDescriptionResponse.json()
setValue(generatedDescription || '')
}, [fields, setValue, hasGenerateDescriptionFn, locale, docInfo])
}, [hasGenerateDescriptionFn, docInfo, getData, locale, setValue])
return (
<div

View File

@@ -5,10 +5,10 @@ import type { FieldType, Options, UploadInputProps } from '@payloadcms/ui'
import {
FieldLabel,
UploadInput,
useAllFormFields,
useConfig,
useDocumentInfo,
useField,
useForm,
useLocale,
useTranslation,
} from '@payloadcms/ui'
@@ -32,7 +32,7 @@ export const MetaImage: React.FC<MetaImageProps> = (props) => {
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
const locale = useLocale()
const [fields] = useAllFormFields()
const { getData } = useForm()
const docInfo = useDocumentInfo()
const { errorMessage, setValue, showError, value } = field
@@ -43,7 +43,7 @@ export const MetaImage: React.FC<MetaImageProps> = (props) => {
const genImageResponse = await fetch('/api/plugin-seo/generate-image', {
body: JSON.stringify({
...docInfo,
doc: { ...fields },
doc: { ...getData() },
locale: typeof locale === 'object' ? locale?.code : locale,
} satisfies Parameters<GenerateImage>[0]),
credentials: 'include',
@@ -56,7 +56,7 @@ export const MetaImage: React.FC<MetaImageProps> = (props) => {
const { result: generatedImage } = await genImageResponse.json()
setValue(generatedImage || '')
}, [fields, setValue, hasGenerateImageFn, locale, docInfo])
}, [hasGenerateImageFn, docInfo, getData, locale, setValue])
const hasImage = Boolean(value)

View File

@@ -5,10 +5,10 @@ import type { FieldType, FormFieldBase, Options } from '@payloadcms/ui'
import {
FieldLabel,
TextInput,
useAllFormFields,
useDocumentInfo,
useField,
useFieldProps,
useForm,
useLocale,
useTranslation,
} from '@payloadcms/ui'
@@ -39,7 +39,7 @@ export const MetaTitle: React.FC<MetaTitleProps> = (props) => {
} as Options)
const locale = useLocale()
const [fields] = useAllFormFields()
const { getData } = useForm()
const docInfo = useDocumentInfo()
const { errorMessage, setValue, showError, value } = field
@@ -50,7 +50,7 @@ export const MetaTitle: React.FC<MetaTitleProps> = (props) => {
const genTitleResponse = await fetch('/api/plugin-seo/generate-title', {
body: JSON.stringify({
...docInfo,
doc: { ...fields },
doc: { ...getData() },
locale: typeof locale === 'object' ? locale?.code : locale,
} satisfies Parameters<GenerateTitle>[0]),
credentials: 'include',
@@ -63,7 +63,7 @@ export const MetaTitle: React.FC<MetaTitleProps> = (props) => {
const { result: generatedTitle } = await genTitleResponse.json()
setValue(generatedTitle || '')
}, [fields, setValue, hasGenerateTitleFn, locale, docInfo])
}, [hasGenerateTitleFn, docInfo, getData, locale, setValue])
return (
<div

View File

@@ -151,6 +151,31 @@ export const translations = {
tooShort: 'Zbyt krótkie',
},
},
ru: {
$schema: './translation-schema.json',
'plugin-seo': {
almostThere: 'Почти готово',
autoGenerate: 'Сгенерировать автоматически',
bestPractices: 'лучшие практики',
characterCount: '{{current}}/{{minLength}}-{{maxLength}} символов, ',
charactersLeftOver: 'осталось {{characters}} символов',
charactersToGo: 'на {{characters}} символов меньше',
charactersTooMany: 'на {{characters}} символов больше',
checksPassing: '{{current}}/{{max}} проверок пройдено',
good: 'Хорошо',
imageAutoGenerationTip: 'Автогенерация использует выбранное главное изображение.',
lengthTipDescription:
'Должно быть от {{minLength}} до {{maxLength}} символов. Для помощи в написании качественных метаописаний см.',
lengthTipTitle:
'Должно быть от {{minLength}} до {{maxLength}} символов. Для помощи в написании качественных метазаголовков см.',
noImage: 'Нет изображения',
preview: 'Предварительный просмотр',
previewDescription:
'Фактические результаты могут отличаться в зависимости от контента и релевантности поиска.',
tooLong: 'Слишком длинно',
tooShort: 'Слишком коротко',
},
},
uk: {
$schema: './translation-schema.json',
'plugin-seo': {

View File

@@ -0,0 +1,22 @@
{
"$schema": "./translation-schema.json",
"plugin-seo": {
"almostThere": "Почти готово",
"autoGenerate": "Сгенерировать автоматически",
"bestPractices": "лучшие практики",
"characterCount": "{{current}}/{{minLength}}-{{maxLength}} символов, ",
"charactersLeftOver": "осталось {{characters}} символов",
"charactersToGo": "на {{characters}} символов меньше",
"charactersTooMany": "на {{characters}} символов больше",
"checksPassing": "{{current}}/{{max}} проверок пройдено",
"good": "Хорошо",
"imageAutoGenerationTip": "Автогенерация использует выбранное главное изображение.",
"lengthTipDescription": "Должно быть от {{minLength}} до {{maxLength}} символов. Для помощи в написании качественных метаописаний см.",
"lengthTipTitle": "Должно быть от {{minLength}} до {{maxLength}} символов. Для помощи в написании качественных метазаголовков см.",
"noImage": "Нет изображения",
"preview": "Предварительный просмотр",
"previewDescription": "Фактические результаты могут отличаться в зависимости от контента и релевантности поиска.",
"tooLong": "Слишком длинно",
"tooShort": "Слишком коротко"
}
}

View File

@@ -1,22 +1,22 @@
import type { DocumentInfoContext } from '@payloadcms/ui'
import type { Field, TextField, TextareaField, UploadField } from 'payload'
export type GenerateTitle = <T = any>(
export type GenerateTitle<T = any> = (
args: DocumentInfoContext & { doc: T; locale?: string },
) => Promise<string> | string
export type GenerateDescription = <T = any>(
export type GenerateDescription<T = any> = (
args: DocumentInfoContext & {
doc: T
locale?: string
},
) => Promise<string> | string
export type GenerateImage = <T = any>(
export type GenerateImage<T = any> = (
args: DocumentInfoContext & { doc: T; locale?: string },
) => Promise<string> | string
export type GenerateURL = <T = any>(
export type GenerateURL<T = any> = (
args: DocumentInfoContext & { doc: T; locale?: string },
) => Promise<string> | string

View File

@@ -2,7 +2,13 @@
import type { FormField, UIField } from 'payload'
import { useAllFormFields, useDocumentInfo, useLocale, useTranslation } from '@payloadcms/ui'
import {
useAllFormFields,
useDocumentInfo,
useForm,
useLocale,
useTranslation,
} from '@payloadcms/ui'
import React, { useEffect, useState } from 'react'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
@@ -17,6 +23,7 @@ export const Preview: React.FC<PreviewProps> = ({ hasGenerateURLFn }) => {
const locale = useLocale()
const [fields] = useAllFormFields()
const { getData } = useForm()
const docInfo = useDocumentInfo()
const {
@@ -31,7 +38,7 @@ export const Preview: React.FC<PreviewProps> = ({ hasGenerateURLFn }) => {
const genURLResponse = await fetch('/api/plugin-seo/generate-url', {
body: JSON.stringify({
...docInfo,
doc: { ...fields },
doc: { ...getData() },
locale: typeof locale === 'object' ? locale?.code : locale,
} satisfies Parameters<GenerateURL>[0]),
credentials: 'include',
@@ -49,7 +56,7 @@ export const Preview: React.FC<PreviewProps> = ({ hasGenerateURLFn }) => {
if (hasGenerateURLFn && !href) {
void getHref()
}
}, [fields, href, locale, docInfo, hasGenerateURLFn])
}, [fields, href, locale, docInfo, hasGenerateURLFn, getData])
return (
<div>

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.56",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -73,12 +73,8 @@
"payload": "workspace:*"
},
"peerDependencies": {
"@faceless-ui/modal": "3.0.0-beta.0",
"@faceless-ui/scroll-info": "2.0.0-beta.0",
"@lexical/headless": "0.16.0",
"@lexical/link": "0.16.0",
"@lexical/list": "0.16.0",
"@lexical/mark": "0.16.0",
"@lexical/markdown": "0.16.0",
"@lexical/react": "0.16.0",
"@lexical/rich-text": "0.16.0",

View File

@@ -1,6 +1,8 @@
/* eslint-disable perfectionist/sort-exports */
'use client'
export { slashMenuBasicGroupWithItems } from '../../features/shared/slashMenu/basicGroup.js'
export { RichTextCell } from '../../cell/index.js'
export { AlignFeatureClient } from '../../features/align/feature.client.js'
export { BlockquoteFeatureClient } from '../../features/blockquote/feature.client.js'
@@ -11,7 +13,6 @@ export { TreeViewFeatureClient } from '../../features/debug/treeView/feature.cli
export { BoldFeatureClient } from '../../features/format/bold/feature.client.js'
export { InlineCodeFeatureClient } from '../../features/format/inlineCode/feature.client.js'
export { ItalicFeatureClient } from '../../features/format/italic/feature.client.js'
export { toolbarFormatGroupWithItems } from '../../features/format/shared/toolbarFormatGroup.js'
export { StrikethroughFeatureClient } from '../../features/format/strikethrough/feature.client.js'
export { SubscriptFeatureClient } from '../../features/format/subscript/feature.client.js'
export { SuperscriptFeatureClient } from '../../features/format/superscript/feature.client.js'
@@ -29,6 +30,7 @@ export { ParagraphFeatureClient } from '../../features/paragraph/feature.client.
export { RelationshipFeatureClient } from '../../features/relationship/feature.client.js'
export { toolbarFormatGroupWithItems } from '../../features/format/shared/toolbarFormatGroup.js'
export { toolbarAddDropdownGroupWithItems } from '../../features/shared/toolbar/addDropdownGroup.js'
export { toolbarFeatureButtonsGroupWithItems } from '../../features/shared/toolbar/featureButtonsGroup.js'
export { toolbarTextDropdownGroupWithItems } from '../../features/shared/toolbar/textDropdownGroup.js'
@@ -121,3 +123,5 @@ export {
$isBlockNode,
BlockNode,
} from '../../features/blocks/nodes/BlocksNode.js'
export { FieldsDrawer } from '../../utilities/fieldsDrawer/Drawer.js'

View File

@@ -1,3 +1,6 @@
import type { SerializedQuoteNode as _SerializedQuoteNode } from '@lexical/rich-text'
import type { Spread } from 'lexical'
import { QuoteNode } from '@lexical/rich-text'
// eslint-disable-next-line payload/no-imports-from-exports-dir
@@ -8,6 +11,13 @@ import { createNode } from '../typeUtilities.js'
import { i18n } from './i18n.js'
import { MarkdownTransformer } from './markdownTransformer.js'
export type SerializedQuoteNode = Spread<
{
type: 'quote'
},
_SerializedQuoteNode
>
export const BlockquoteFeature = createServerFeature({
feature: {
ClientFeature: BlockquoteFeatureClient,

View File

@@ -72,7 +72,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
apiRoute: config.routes.api,
body: {
id,
data: JSON.parse(JSON.stringify(formData)),
data: formData,
operation: 'update',
schemaPath: schemaFieldsPath,
},

View File

@@ -1,6 +1,5 @@
import type { Block, BlockField, Config, Field } from 'payload'
import { traverseFields } from '@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields'
import { baseBlockFields, fieldsToJSONSchema, formatLabels, sanitizeFields } from 'payload'
import type { BlocksFeatureClientProps } from './feature.client.js'
@@ -57,9 +56,7 @@ export const BlocksFeature = createServerFeature<
return {
ClientFeature: BlocksFeatureClient,
clientFeatureProps: clientProps,
generateSchemaMap: ({ config, i18n, props }) => {
const validRelationships = config.collections.map((c) => c.slug) || []
generateSchemaMap: ({ props }) => {
/**
* Add sub-fields to the schemaMap. E.g. if you have an array field as part of the block, and it runs addRow, it will request these
* sub-fields from the component map. Thus, we need to put them in the component map here.
@@ -68,15 +65,6 @@ export const BlocksFeature = createServerFeature<
for (const block of props.blocks) {
schemaMap.set(block.slug, block.fields || [])
traverseFields({
config,
fields: block.fields,
i18n,
schemaMap,
schemaPath: block.slug,
validRelationships,
})
}
return schemaMap

View File

@@ -30,7 +30,9 @@ const BlockComponent = React.lazy(() =>
export type SerializedBlockNode = Spread<
{
children?: never // required so that our typed editor state doesn't automatically add children
fields: BlockFields
type: 'block'
},
SerializedDecoratorBlockNode
>
@@ -102,7 +104,7 @@ export class BlockNode extends DecoratorBlockNode {
exportJSON(): SerializedBlockNode {
return {
...super.exportJSON(),
type: this.getType(),
type: 'block',
fields: this.getFields(),
version: 2,
}

View File

@@ -9,7 +9,7 @@ export type HTMLConverterFeatureProps = {
}
// This is just used to save the props on the richText field
export const HTMLConverterFeature = createServerFeature({
export const HTMLConverterFeature = createServerFeature<HTMLConverterFeatureProps>({
feature: {},
key: 'htmlConverter',
})

View File

@@ -1,4 +1,8 @@
import type { HeadingTagType } from '@lexical/rich-text'
import type {
SerializedHeadingNode as _SerializedHeadingNode,
HeadingTagType,
} from '@lexical/rich-text'
import type { Spread } from 'lexical'
import { HeadingNode } from '@lexical/rich-text'
@@ -10,6 +14,13 @@ import { createNode } from '../typeUtilities.js'
import { i18n } from './i18n.js'
import { MarkdownTransformer } from './markdownTransformer.js'
export type SerializedHeadingNode = Spread<
{
type: 'heading'
},
_SerializedHeadingNode
>
export type HeadingFeatureProps = {
enabledHeadingSizes?: HeadingTagType[]
}

View File

@@ -50,13 +50,13 @@ export const i18n: Partial<GenericLanguages> = {
label: '[SURAT]\n\nKepala {{headingLevel}}',
},
nb: {
label: 'Overskrift {{overskriftsnivå}}',
label: 'Overskrift {{headingLevel}}',
},
nl: {
label: 'Kop {{headingLevel}}',
},
pl: {
label: 'Nagłówek {{poziom nagłówka}}',
label: 'Nagłówek {{headingLevel}}',
},
pt: {
label: 'Cabeçalho {{headingLevel}}',

View File

@@ -6,6 +6,7 @@ import type {
LexicalCommand,
LexicalNode,
SerializedLexicalNode,
Spread,
} from 'lexical'
import { addClassNamesToElement } from '@lexical/utils'
@@ -21,7 +22,13 @@ const HorizontalRuleComponent = React.lazy(() =>
/**
* Serialized representation of a horizontal rule node. Serialized = converted to JSON. This is what is stored in the database / in the lexical editor state.
*/
export type SerializedHorizontalRuleNode = SerializedLexicalNode
export type SerializedHorizontalRuleNode = Spread<
{
children?: never // required so that our typed editor state doesn't automatically add children
type: 'horizontalrule'
},
SerializedLexicalNode
>
export const INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand<void> = createCommand(
'INSERT_HORIZONTAL_RULE_COMMAND',

View File

@@ -1,50 +0,0 @@
@import '../../../scss/styles.scss';
.lexical-link-edit-drawer {
&__template {
position: relative;
z-index: 1;
padding-top: var(--base);
padding-bottom: calc(var(--base) * 2);
}
&__header {
width: 100%;
margin-bottom: var(--base);
display: flex;
justify-content: space-between;
margin-top: calc(var(--base) * 2.5);
margin-bottom: var(--base);
@include mid-break {
margin-top: calc(var(--base) * 1.5);
}
}
&__header-text {
margin: 0;
}
&__header-close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: var(--base);
height: var(--base);
svg {
width: calc(var(--base) * 2.75);
height: calc(var(--base) * 2.75);
position: relative;
left: calc(var(--base) * -0.825);
top: calc(var(--base) * -0.825);
.stroke {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}
}

View File

@@ -1,9 +0,0 @@
import type { FormState } from 'payload'
import type { LinkFields } from '../nodes/types.js'
export interface Props {
drawerSlug: string
handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
stateData: {} | (LinkFields & { text: string })
}

View File

@@ -1,6 +1,5 @@
import type { CollectionSlug, Config, Field, FieldAffectingData, SanitizedConfig } from 'payload'
import { traverseFields } from '@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields'
import { sanitizeFields } from 'payload'
import { deepCopyObject } from 'payload/shared'
@@ -101,26 +100,14 @@ export const LinkFeature = createServerFeature<
disabledCollections: props.disabledCollections,
enabledCollections: props.enabledCollections,
} as ExclusiveLinkCollectionsProps,
generateSchemaMap: ({ config, i18n }) => {
generateSchemaMap: () => {
if (!sanitizedFields || !Array.isArray(sanitizedFields) || sanitizedFields.length === 0) {
return null
}
const schemaMap = new Map<string, Field[]>()
const validRelationships = config.collections.map((c) => c.slug) || []
schemaMap.set('fields', sanitizedFields)
traverseFields({
config,
fields: sanitizedFields,
i18n,
schemaMap,
schemaPath: 'fields',
validRelationships,
})
return schemaMap
},
i18n,

View File

@@ -41,10 +41,16 @@ export class AutoLinkNode extends LinkNode {
return node
}
// @ts-expect-error
exportJSON(): SerializedAutoLinkNode {
const serialized = super.exportJSON()
return {
...super.exportJSON(),
type: 'autolink',
children: serialized.children,
direction: serialized.direction,
fields: serialized.fields,
format: serialized.format,
indent: serialized.indent,
version: 2,
}
}

View File

@@ -129,7 +129,7 @@ export class LinkNode extends ElementNode {
exportJSON(): SerializedLinkNode {
const returnObject: SerializedLinkNode = {
...super.exportJSON(),
type: this.getType(),
type: 'link',
fields: this.getFields(),
version: 3,
}

View File

@@ -1,4 +1,4 @@
import type { SerializedElementNode, Spread } from 'lexical'
import type { SerializedElementNode, SerializedLexicalNode, Spread } from 'lexical'
export type LinkFields = {
// unknown, custom fields:
@@ -18,11 +18,14 @@ export type LinkFields = {
url: string
}
export type SerializedLinkNode = Spread<
export type SerializedLinkNode<T extends SerializedLexicalNode = SerializedLexicalNode> = Spread<
{
fields: LinkFields
id?: string // optional if AutoLinkNode
type: 'link'
},
SerializedElementNode
SerializedElementNode<T>
>
export type SerializedAutoLinkNode = Omit<SerializedLinkNode, 'id'>
export type SerializedAutoLinkNode<T extends SerializedLexicalNode = SerializedLexicalNode> = {
type: 'autolink'
} & Omit<SerializedLinkNode<T>, 'id' | 'type'>

View File

@@ -24,7 +24,7 @@ import type { LinkPayload } from '../types.js'
import { useEditorConfigContext } from '../../../../../lexical/config/client/EditorConfigProvider.js'
import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode.js'
import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor.js'
import { LinkDrawer } from '../../../drawer/index.js'
import { FieldsDrawer } from '../../../../../utilities/fieldsDrawer/Drawer.js'
import { $isAutoLinkNode } from '../../../nodes/AutoLinkNode.js'
import { $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode.js'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands.js'
@@ -44,7 +44,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const [stateData, setStateData] = useState<{} | (LinkFields & { id?: string; text: string })>({})
const { closeModal, isModalOpen, toggleModal } = useModal()
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
const [isLink, setIsLink] = useState(false)
const [selectedNodes, setSelectedNodes] = useState<LexicalNode[]>([])
@@ -312,50 +312,52 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
)}
</div>
</div>
{isModalOpen(drawerSlug) && (
<LinkDrawer
drawerSlug={drawerSlug}
handleModalSubmit={(fields: FormState, data: Data) => {
closeModal(drawerSlug)
<FieldsDrawer
className="lexical-link-edit-drawer"
data={stateData}
drawerSlug={drawerSlug}
drawerTitle={t('fields:editLink')}
featureKey="link"
handleDrawerSubmit={(fields: FormState, data: Data) => {
closeModal(drawerSlug)
const newLinkPayload = data as LinkFields & { text: string }
const newLinkPayload = data as LinkFields & { text: string }
const bareLinkFields: LinkFields = {
...newLinkPayload,
const bareLinkFields: LinkFields = {
...newLinkPayload,
}
delete bareLinkFields.text
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
editor.update(() => {
const selection = $getSelection()
let linkParent = null
if ($isRangeSelection(selection)) {
linkParent = getSelectedNode(selection).getParent()
} else {
if (selectedNodes.length) {
linkParent = selectedNodes[0].getParent()
}
}
delete bareLinkFields.text
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
editor.update(() => {
const selection = $getSelection()
let linkParent = null
if ($isRangeSelection(selection)) {
linkParent = getSelectedNode(selection).getParent()
} else {
if (selectedNodes.length) {
linkParent = selectedNodes[0].getParent()
}
}
if (linkParent && $isAutoLinkNode(linkParent)) {
const linkNode = $createLinkNode({
fields: bareLinkFields,
})
linkParent.replace(linkNode, true)
}
})
if (linkParent && $isAutoLinkNode(linkParent)) {
const linkNode = $createLinkNode({
fields: bareLinkFields,
})
linkParent.replace(linkNode, true)
}
})
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
// it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
fields: bareLinkFields,
selectedNodes,
text: newLinkPayload.text,
})
}}
stateData={stateData}
/>
)}
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
// it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
fields: bareLinkFields,
selectedNodes,
text: newLinkPayload.text,
})
}}
schemaPathSuffix="fields"
/>
</React.Fragment>
)
}

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