Compare commits
33 Commits
v3.0.0-bet
...
feat/lexic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccf701da50 | ||
|
|
2285624632 | ||
|
|
ef21182eac | ||
|
|
368dd2c167 | ||
|
|
8f346dfb62 | ||
|
|
559c0646fa | ||
|
|
75a3040029 | ||
|
|
2daefb2a81 | ||
|
|
9cdcf20c95 | ||
|
|
37e2da012b | ||
|
|
07f3f273cd | ||
|
|
0017c67f74 | ||
|
|
0a42281de3 | ||
|
|
69a42fa428 | ||
|
|
8c2779c02a | ||
|
|
06da53379a | ||
|
|
4404a3c85c | ||
|
|
11b53c2862 | ||
|
|
8e232e680e | ||
|
|
70957b0d22 | ||
|
|
4375a33706 | ||
|
|
51056769e5 | ||
|
|
abf6e9aa6b | ||
|
|
5ffc5a1248 | ||
|
|
ed73dedd14 | ||
|
|
6b7ec6cbf2 | ||
|
|
35eb16bbec | ||
|
|
f47d6cb23c | ||
|
|
c34aa86da1 | ||
|
|
ae8a5a9cb8 | ||
|
|
d8d5a44895 | ||
|
|
377a478fc2 | ||
|
|
0b2be54011 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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're already familiar with Payload then you'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're already familiar with Payload then you'll have everything you need to get started. You can either start from scratch or use the [Plugin Template](#plugin-template) to get up and running quickly.
|
||||
|
||||
<Banner type="success">
|
||||
To use the template, run `npx create-payload-app@latest -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.0.0-beta.54",
|
||||
"version": "3.0.0-beta.56",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
packages/next/src/utilities/mergeHeaders.ts
Normal file
33
packages/next/src/utilities/mergeHeaders.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
packages/payload/src/admin/elements/Upload.ts
Normal file
3
packages/payload/src/admin/elements/Upload.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { CustomComponent } from '../../config/types.js'
|
||||
|
||||
export type CustomUpload = CustomComponent
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
DocumentTabConfig,
|
||||
DocumentTabProps,
|
||||
} from './elements/Tab.js'
|
||||
export type { CustomUpload } from './elements/Upload.js'
|
||||
export type {
|
||||
WithServerSidePropsComponent,
|
||||
WithServerSidePropsComponentProps,
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,8 @@ export const defaults = {
|
||||
beforeOperation: [],
|
||||
beforeRead: [],
|
||||
beforeValidate: [],
|
||||
me: [],
|
||||
refresh: [],
|
||||
},
|
||||
timestamps: true,
|
||||
upload: false,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -674,6 +674,8 @@ export type {
|
||||
Collection,
|
||||
CollectionConfig,
|
||||
DataFromCollectionSlug,
|
||||
MeHook as CollectionMeHook,
|
||||
RefreshHook as CollectionRefreshHook,
|
||||
RequiredDataFromCollection,
|
||||
RequiredDataFromCollectionSlug,
|
||||
SanitizedCollectionConfig,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
22
packages/plugin-seo/src/translations/ru.json
Normal file
22
packages/plugin-seo/src/translations/ru.json
Normal 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": "Слишком коротко"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user