Compare commits

..

13 Commits

Author SHA1 Message Date
Elliot DeNolf
4375a33706 chore(release): v3.0.0-beta.55 [skip ci] 2024-06-26 16:06:14 -04:00
Alessio Gravili
51056769e5 feat(richtext-lexical): new slate => lexical migration function which migrates all your documents at once (#6947) 2024-06-26 15:40:14 -04:00
Anders Semb Hermansen
abf6e9aa6b fix(richtext-lexical): properly set heading level translation for nb and pl (#6900) 2024-06-26 15:27:26 -04:00
James Mikrut
5ffc5a1248 fix: auth strategy exp (#6945)
## Description

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

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

## Type of change

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

You can do this like so:

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

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

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

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

## Type of change

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

## Checklist:

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,21 @@ keywords: lexical, rich text, editor, headless cms, migrate, migration
While both Slate and Lexical save the editor state in JSON, the structure of the JSON is different.
### Migration via Migration Script (Recommended)
Just import the `migrateSlateToLexical` function we provide, pass it the `payload` object and run it. Depending on the amount of collections, this might take a while.
IMPORTANT: This will overwrite all slate data. We recommend doing the following first:
1. Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support.
2. Make every richText field a lexical editor. This script will only convert lexical richText fields with old Slate data
3. Add the SlateToLexicalFeature (as seen below) first, and test it out by loading up the admin panel, to see if the migrator works as expected. You might have to build some custom converters for some fields first in order to convert custom Slate nodes. The SlateToLexicalFeature is where the converters are stored. Only fields with this feature added will be migrated.
```ts
import { migrateSlateToLexical } from '@payloadcms/richtext-lexical'
await migrateSlateToLexical({ payload })
```
### Migration via SlateToLexicalFeature
One way to handle this is to just give your lexical editor the ability to read the slate JSON.
@@ -42,7 +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.
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.
### Migration via migration script

View File

@@ -89,7 +89,7 @@ import { CallToAction } from '../blocks/CallToAction'
{
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
features: ({ defaultFeatures, rootFeatures }) => [
...defaultFeatures,
LinkFeature({
// Example showing how to customize the built-in fields
@@ -134,6 +134,15 @@ import { CallToAction } from '../blocks/CallToAction'
}
```
`features` can be both an array of features, or a function returning an array of features. The function provides the following props:
| Prop | Description |
|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`defaultFeatures`** | This opinionated array contains all "recommended" default features. You can see which features are included in the default features in the table below. |
| **`rootFeatures`** | This array contains all features that are enabled in the root richText editor (the one defined in the payload.config.ts). If this field is the root richText editor, or if the root richText editor is not a lexical editor, this array will be empty. |
## Features overview
Here's an overview of all the included features:
@@ -169,3 +178,77 @@ 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,
} from '@payloadcms/richtext-lexical'
const editorState: TypedEditorState<
| SerializedAutoLinkNode
| SerializedBlockNode
| SerializedHorizontalRuleNode
| SerializedLinkNode
| SerializedListItemNode
| SerializedListNode
| SerializedParagraphNode
| SerializedQuoteNode
| SerializedRelationshipNode
| SerializedTextNode
| SerializedUploadNode
> = {
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,
},
],
},
}
```
This is a type-safe representation of the editor state. Looking at the auto-suggestions of `type` it will show you all the possible node types you can use.
Make sure to only use types exported from `@payloadcms/richtext-lexical`, not from the lexical core packages. We only have control over types we export and can guarantee that those are correct, even though lexical core may export types with identical names.
### Automatic type generation
Lexical does not generate the accurate type definitions for your richText fields for you yet - this will be improved in the future. Currently, it only outputs the rough shape of the editor JSON which you can enhance using type assertions.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import type { ClientUser, User } from '../types.js'
export type MeOperationResult = {
collection?: string
exp?: number
strategy?: string
token?: string
user?: ClientUser
}
@@ -49,6 +50,7 @@ export const meOperation = async ({
result = {
collection: req.user.collection,
strategy: req.user._strategy,
user,
}

View File

@@ -14,6 +14,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
export type Result = {
exp: number
refreshedToken: string
strategy?: string
user: Document
}
@@ -88,6 +89,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
let result: Result = {
exp,
refreshedToken,
strategy: args.req.user._strategy,
user,
}

View File

@@ -8,11 +8,6 @@ import {
} from '../../config/shared/componentSchema.js'
import { openGraphSchema } from '../../config/shared/openGraphSchema.js'
const strategyBaseSchema = joi.object().keys({
logout: joi.boolean(),
refresh: joi.boolean(),
})
const collectionSchema = joi.object().keys({
slug: joi.string().required(),
access: joi.object({
@@ -36,6 +31,7 @@ const collectionSchema = joi.object().keys({
PublishButton: componentSchema,
SaveButton: componentSchema,
SaveDraftButton: componentSchema,
Upload: componentSchema,
}),
views: joi.object({
Edit: joi.alternatives().try(
@@ -184,6 +180,7 @@ const collectionSchema = joi.object().keys({
.unknown(),
),
mimeTypes: joi.array().items(joi.string()),
modifyResponseHeaders: joi.func(),
resizeOptions: joi
.object()
.keys({

View File

@@ -6,6 +6,7 @@ import type {
CustomPublishButton,
CustomSaveButton,
CustomSaveDraftButton,
CustomUpload,
} from '../../admin/types.js'
import type { Auth, ClientUser, IncomingAuthType } from '../../auth/types.js'
import type {
@@ -254,6 +255,11 @@ export type CollectionAdminOptions = {
* + autosave must be disabled
*/
SaveDraftButton?: CustomSaveDraftButton
/**
* Replaces the "Upload" section
* + upload must be enabled
*/
Upload?: CustomUpload
}
views?: {
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ export const ChecklistFeature = createServerFeature({
}),
createNode({
converters: {
html: ListItemHTMLConverter,
html: ListItemHTMLConverter as any,
},
node: ListItemNode,
}),

View File

@@ -1,9 +1,8 @@
import type { SerializedListItemNode, SerializedListNode } from '@lexical/list'
import { ListItemNode, ListNode } from '@lexical/list'
import { v4 as uuidv4 } from 'uuid'
import type { HTMLConverter } from '../converters/html/converter/types.js'
import type { SerializedListItemNode, SerializedListNode } from './plugin/index.js'
import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js'

View File

@@ -25,7 +25,7 @@ export const OrderedListFeature = createServerFeature({
}),
createNode({
converters: {
html: ListItemHTMLConverter,
html: ListItemHTMLConverter as any,
},
node: ListItemNode,
}),

View File

@@ -1,9 +1,31 @@
'use client'
import type {
SerializedListItemNode as _SerializedListItemNode,
SerializedListNode as _SerializedListNode,
} from '@lexical/list'
import type { Spread } from 'lexical'
import { ListPlugin } from '@lexical/react/LexicalListPlugin.js'
import React from 'react'
import type { PluginComponent } from '../../typesClient.js'
export type SerializedListItemNode = Spread<
{
checked?: boolean
type: 'listitem'
},
_SerializedListItemNode
>
export type SerializedListNode = Spread<
{
checked?: boolean
type: 'list'
},
_SerializedListNode
>
export const LexicalListPlugin: PluginComponent<undefined> = () => {
return <ListPlugin />
}

View File

@@ -22,7 +22,7 @@ export const UnorderedListFeature = createServerFeature({
}),
createNode({
converters: {
html: ListItemHTMLConverter,
html: ListItemHTMLConverter as any,
},
node: ListItemNode,
}),

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { SerializedListNode } from '@lexical/list'
import type { SerializedListNode } from '../../../../../lists/plugin/index.js'
import type { LexicalPluginNodeConverter } from '../../types.js'
import { convertLexicalPluginNodesToLexical } from '../../index.js'

View File

@@ -1,5 +1,4 @@
import type { SerializedListItemNode } from '@lexical/list'
import type { SerializedListItemNode } from '../../../../../lists/plugin/index.js'
import type { LexicalPluginNodeConverter } from '../../types.js'
import { convertLexicalPluginNodesToLexical } from '../../index.js'

View File

@@ -1,5 +1,4 @@
import type { SerializedQuoteNode } from '@lexical/rich-text'
import type { SerializedQuoteNode } from '../../../../../blockquote/feature.server.js'
import type { LexicalPluginNodeConverter } from '../../types.js'
import { convertLexicalPluginNodesToLexical } from '../../index.js'

View File

@@ -1,5 +1,4 @@
import type { SerializedQuoteNode } from '@lexical/rich-text'
import type { SerializedQuoteNode } from '../../../../../blockquote/feature.server.js'
import type { SlateNodeConverter } from '../../types.js'
import { convertSlateNodesToLexical } from '../../index.js'

View File

@@ -1,5 +1,4 @@
import type { SerializedListItemNode } from '@lexical/list'
import type { SerializedListItemNode } from '../../../../../lists/plugin/index.js'
import type { SlateNodeConverter } from '../../types.js'
import { convertSlateNodesToLexical } from '../../index.js'

View File

@@ -1,5 +1,4 @@
import type { SerializedListNode } from '@lexical/list'
import type { SerializedListNode } from '../../../../../lists/plugin/index.js'
import type { SlateNodeConverter } from '../../types.js'
import { convertSlateNodesToLexical } from '../../index.js'

View File

@@ -1,5 +1,4 @@
import type { SerializedListNode } from '@lexical/list'
import type { SerializedListNode } from '../../../../../lists/plugin/index.js'
import type { SlateNodeConverter } from '../../types.js'
import { convertSlateNodesToLexical } from '../../index.js'

View File

@@ -18,7 +18,12 @@ export type SlateToLexicalFeatureProps = {
| SlateNodeConverterProvider[]
}
export const SlateToLexicalFeature = createServerFeature<SlateToLexicalFeatureProps>({
export const SlateToLexicalFeature = createServerFeature<
SlateToLexicalFeatureProps,
{
converters?: SlateNodeConverterProvider[]
}
>({
feature: ({ props }) => {
if (!props) {
props = {}
@@ -56,7 +61,9 @@ export const SlateToLexicalFeature = createServerFeature<SlateToLexicalFeaturePr
node: UnknownConvertedNode,
},
],
sanitizedServerFeatureProps: props,
sanitizedServerFeatureProps: {
converters,
},
}
},
key: 'slateToLexical',

View File

@@ -10,6 +10,7 @@ import type {
NodeKey,
Spread,
} from 'lexical'
import type { CollectionSlug } from 'payload'
import type { JSX } from 'react'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
@@ -22,11 +23,14 @@ const RelationshipComponent = React.lazy(() =>
)
export type RelationshipData = {
relationTo: string
relationTo: CollectionSlug
value: number | string
}
export type SerializedRelationshipNode = Spread<RelationshipData, SerializedDecoratorBlockNode>
export type SerializedRelationshipNode = Spread<RelationshipData, SerializedDecoratorBlockNode> & {
children?: never // required so that our typed editor state doesn't automatically add children
type: 'relationship'
}
function $relationshipElementToNode(domNode: HTMLDivElement): DOMConversionOutput | null {
const id = domNode.getAttribute('data-lexical-relationship-id')
@@ -129,7 +133,7 @@ export class RelationshipNode extends DecoratorBlockNode {
return {
...super.exportJSON(),
...this.getData(),
type: this.getType(),
type: 'relationship',
version: 2,
}
}

View File

@@ -6,17 +6,44 @@ import type { EditorConfigContextType } from '../../lexical/config/client/Editor
export type ToolbarGroup =
| {
ChildComponent?: React.FC
/**
* All toolbar items part of this toolbar group need to be added here.
*/
items: Array<ToolbarGroupItem>
/**
* Each toolbar group needs to have a unique key. Groups with the same keys will have their items merged together.
*/
key: string
/**
* Determines where the toolbar group will be.
*/
order?: number
type: 'dropdown'
/**
* Controls the toolbar group type. Set to `buttons` to create a buttons toolbar group, which displays toolbar items horizontally using only their icons.
*/
type: 'buttons'
}
| {
/**
* The dropdown toolbar ChildComponent allows you to pass in a React Component which will be displayed within the dropdown button.
*/
ChildComponent?: React.FC
/**
* All toolbar items part of this toolbar group need to be added here.
*/
items: Array<ToolbarGroupItem>
/**
* Each toolbar group needs to have a unique key. Groups with the same keys will have their items merged together.
*/
key: string
/**
* Determines where the toolbar group will be.
*/
order?: number
type: 'buttons'
/**
* 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.
*/
type: 'dropdown'
}
export type ToolbarGroupItem = {

View File

@@ -154,9 +154,9 @@ export type ResolvedClientFeature<ClientFeatureProps> = ClientFeature<ClientFeat
order: number
}
export type ResolvedClientFeatureMap = Map<string, ResolvedClientFeature<unknown>>
export type ResolvedClientFeatureMap = Map<string, ResolvedClientFeature<any>>
export type ClientFeatureProviderMap = Map<string, FeatureProviderClient<unknown, unknown>>
export type ClientFeatureProviderMap = Map<string, FeatureProviderClient<any, any>>
/**
* Plugins are react components which get added to the editor. You can use them to interact with lexical, e.g. to create a command which creates a node, or opens a modal, or some other more "outside" functionality

View File

@@ -220,6 +220,10 @@ export type BeforeValidateNodeHook<T extends SerializedLexicalNode> = (
// Define the node with hooks that use the node's exportJSON return type
export type NodeWithHooks<T extends LexicalNode = any> = {
/**
* 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.
*/
converters?: {
html?: HTMLConverter<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
}
@@ -231,13 +235,25 @@ export type NodeWithHooks<T extends LexicalNode = any> = {
node: ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>
req: PayloadRequestWithData
}) => Field[] | null
/**
* If a node includes sub-fields, the sub-fields data needs to be returned here, alongside `getSubFields` which returns their schema.
*/
getSubFieldsData?: (args: {
node: ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>
req: PayloadRequestWithData
}) => Record<string, unknown>
/**
* 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.
*/
graphQLPopulationPromises?: Array<
PopulationPromise<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
>
/**
* Just like payload fields, you can provide hooks which are run for this specific node. These are called Node Hooks.
*/
hooks?: {
afterChange?: Array<AfterChangeNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
afterRead?: Array<AfterReadNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
@@ -246,7 +262,14 @@ export type NodeWithHooks<T extends LexicalNode = any> = {
BeforeValidateNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
>
}
/**
* The actual lexical node needs to be provided here. This also supports [lexical node replacements](https://lexical.dev/docs/concepts/node-replacement).
*/
node: Klass<T> | LexicalNodeReplacement
/**
* 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.
*/
validations?: Array<NodeValidation<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
}
@@ -333,12 +356,12 @@ export type ResolvedServerFeature<ServerProps, ClientFeatureProps> = ServerFeatu
order: number
}
export type ResolvedServerFeatureMap = Map<string, ResolvedServerFeature<unknown, unknown>>
export type ResolvedServerFeatureMap = Map<string, ResolvedServerFeature<any, any>>
export type ServerFeatureProviderMap = Map<string, FeatureProviderServer<unknown, unknown, unknown>>
export type ServerFeatureProviderMap = Map<string, FeatureProviderServer<any, any, any>>
export type SanitizedServerFeatures = Required<
Pick<ResolvedServerFeature<unknown, unknown>, 'i18n' | 'markdownTransformers' | 'nodes'>
Pick<ResolvedServerFeature<any, any>, 'i18n' | 'markdownTransformers' | 'nodes'>
> & {
/** The node types mapped to their converters */
converters: {

View File

@@ -8,6 +8,7 @@ import type {
NodeKey,
Spread,
} from 'lexical'
import type { CollectionSlug } from 'payload'
import type { JSX } from 'react'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
@@ -25,7 +26,7 @@ export type UploadData = {
[key: string]: unknown
}
id: string
relationTo: string
relationTo: CollectionSlug
value: number | string
}
@@ -66,7 +67,10 @@ function $convertUploadElement(domNode: HTMLImageElement): DOMConversionOutput |
return null
}
export type SerializedUploadNode = Spread<UploadData, SerializedDecoratorBlockNode>
export type SerializedUploadNode = Spread<UploadData, SerializedDecoratorBlockNode> & {
children?: never // required so that our typed editor state doesn't automatically add children
type: 'upload'
}
export class UploadNode extends DecoratorBlockNode {
__data: UploadData
@@ -148,7 +152,7 @@ export class UploadNode extends DecoratorBlockNode {
return {
...super.exportJSON(),
...this.getData(),
type: this.getType(),
type: 'upload',
version: 3,
}
}

View File

@@ -83,7 +83,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
}
}
let features: FeatureProviderServer<unknown, unknown, unknown>[] = []
let features: FeatureProviderServer<any, any, any>[] = []
let resolvedFeatureMap: ResolvedServerFeatureMap
let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
@@ -811,11 +811,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
export { AlignFeature } from './features/align/feature.server.js'
export { BlockquoteFeature } from './features/blockquote/feature.server.js'
export { BlocksFeature, type BlocksFeatureProps } from './features/blocks/feature.server.js'
export {
type BlockFields,
BlockNode,
type SerializedBlockNode,
} from './features/blocks/nodes/BlocksNode.js'
export { type BlockFields, BlockNode } from './features/blocks/nodes/BlocksNode.js'
export { LinebreakHTMLConverter } from './features/converters/html/converter/converters/linebreak.js'
export { ParagraphHTMLConverter } from './features/converters/html/converter/converters/paragraph.js'
@@ -850,11 +846,7 @@ export { LinkFeature, type LinkFeatureServerProps } from './features/link/featur
export { AutoLinkNode } from './features/link/nodes/AutoLinkNode.js'
export { LinkNode } from './features/link/nodes/LinkNode.js'
export type {
LinkFields,
SerializedAutoLinkNode,
SerializedLinkNode,
} from './features/link/nodes/types.js'
export type { LinkFields } from './features/link/nodes/types.js'
export { ChecklistFeature } from './features/lists/checklist/feature.server.js'
export { OrderedListFeature } from './features/lists/orderedList/feature.server.js'
export { UnorderedListFeature } from './features/lists/unorderedList/feature.server.js'
@@ -889,7 +881,6 @@ export {
export {
type RelationshipData,
RelationshipNode,
type SerializedRelationshipNode,
} from './features/relationship/nodes/RelationshipNode.js'
export { FixedToolbarFeature } from './features/toolbars/fixed/feature.server.js'
@@ -936,11 +927,7 @@ export type {
export { UploadFeature } from './features/upload/feature.server.js'
export type { UploadFeatureProps } from './features/upload/feature.server.js'
export {
type SerializedUploadNode,
type UploadData,
UploadNode,
} from './features/upload/nodes/UploadNode.js'
export { type UploadData, UploadNode } from './features/upload/nodes/UploadNode.js'
export type { EditorConfigContextType } from './lexical/config/client/EditorConfigProvider.js'
export {
@@ -990,3 +977,6 @@ export { defaultRichTextValue } from './populateGraphQL/defaultValue.js'
export type { LexicalEditorProps, LexicalRichTextAdapter } from './types.js'
export { createServerFeature } from './utilities/createServerFeature.js'
export { migrateSlateToLexical } from './utilities/migrateSlateToLexical/index.js'
export * from './nodeTypes.js'

View File

@@ -30,7 +30,7 @@ export const defaultEditorLexicalConfig: LexicalEditorConfig = {
theme: LexicalEditorTheme,
}
export const defaultEditorFeatures: FeatureProviderServer<unknown, unknown, unknown>[] = [
export const defaultEditorFeatures: FeatureProviderServer<any, any, any>[] = [
BoldFeature(),
ItalicFeature(),
UnderlineFeature(),

View File

@@ -13,7 +13,7 @@ import type {
import type { LexicalFieldAdminProps } from '../../types.js'
export type ServerEditorConfig = {
features: FeatureProviderServer<unknown, unknown, unknown>[]
features: FeatureProviderServer<any, any, any>[]
lexical?: LexicalEditorConfig
}
@@ -24,7 +24,7 @@ export type SanitizedServerEditorConfig = {
}
export type ClientEditorConfig = {
features: FeatureProviderClient<unknown, unknown>[]
features: FeatureProviderClient<any, any>[]
lexical?: LexicalEditorConfig
}

View File

@@ -9,9 +9,9 @@ export type SlashMenuItem = {
/** 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. */
key: string
/**
* Keywords are used in order to match the item for different texts typed after the '/'.
* 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.
* Additionally to the keywords, the label and key will be used to match the correct slash menu item.
* In addition to the keywords, the label and key will be used to find the right slash menu item.
*/
keywords?: Array<string>
/** The label will be displayed in your slash menu item. In order to make use of i18n, this can be a function. */
@@ -21,9 +21,15 @@ export type SlashMenuItem = {
}
export type SlashMenuGroup = {
/**
* An array of `SlashMenuItem`'s which will be displayed in the slash menu.
*/
items: Array<SlashMenuItem>
// Used for class names and, if label is not provided, for display.
/**
* Used for class names and, if label is not provided, for display. Slash menus with the same key will have their items merged together.
*/
key: string
/** The label will be displayed before your Slash Menu group. In order to make use of i18n, this can be a function. */
label?: (({ i18n }: { i18n: I18nClient<{}, string> }) => string) | string
}

View File

@@ -0,0 +1,48 @@
import type {
SerializedEditorState,
SerializedElementNode,
SerializedLexicalNode,
Spread,
TextModeType,
} from 'lexical'
export type { SerializedQuoteNode } from './features/blockquote/feature.server.js'
export type { SerializedBlockNode } from './features/blocks/nodes/BlocksNode.js'
export type { SerializedHorizontalRuleNode } from './features/horizontalRule/nodes/HorizontalRuleNode.js'
export type { SerializedAutoLinkNode, SerializedLinkNode } from './features/link/nodes/types.js'
export type { SerializedListItemNode, SerializedListNode } from './features/lists/plugin/index.js'
export type { SerializedRelationshipNode } from './features/relationship/nodes/RelationshipNode.js'
export type { SerializedUploadNode } from './features/upload/nodes/UploadNode.js'
export type SerializedParagraphNode<T extends SerializedLexicalNode = SerializedLexicalNode> =
Spread<
{
textFormat: number
type: 'paragraph'
},
SerializedElementNode<T>
>
export type SerializedTextNode = Spread<
{
children?: never // required so that our typed editor state doesn't automatically add children
detail: number
format: number
mode: TextModeType
style: string
text: string
type: 'text'
},
SerializedLexicalNode
>
type RecursiveNodes<T extends SerializedLexicalNode, Depth extends number = 4> = Depth extends 0
? T
: { children?: RecursiveNodes<T, DecrementDepth<Depth>>[] } & T
type DecrementDepth<N extends number> = [0, 0, 1, 2, 3, 4][N]
export type TypedEditorState<T extends SerializedLexicalNode = SerializedLexicalNode> =
SerializedEditorState<RecursiveNodes<T>>

View File

@@ -39,7 +39,7 @@ export type LexicalEditorProps = {
defaultFeatures: FeatureProviderServer<any, any, any>[]
/**
* 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
* If this field is the root richText editor, or if the root richText editor is not a lexical editor, this array will be empty.
*
* @Example
*

View File

@@ -0,0 +1,164 @@
import type { CollectionConfig, Field, GlobalConfig, Payload } from 'payload'
import { migrateDocumentFields } from './recurse.js'
/**
* This goes through every single collection and field in the payload config, and migrates its data from Slate to Lexical. This does not support sub-fields within slate.
*
* It will only translate fields fulfilling all these requirements:
* - field schema uses lexical editor
* - lexical editor has SlateToLexicalFeature added
* - saved field data is in Slate format
*
* @param payload
*/
export async function migrateSlateToLexical({ payload }: { payload: Payload }) {
const collections = payload.config.collections
const allLocales = payload.config.localization ? payload.config.localization.localeCodes : [null]
const totalCollections = collections.length
for (const locale of allLocales) {
let curCollection = 0
for (const collection of collections) {
curCollection++
await migrateCollection({
collection,
cur: curCollection,
locale,
max: totalCollections,
payload,
})
}
for (const global of payload.config.globals) {
await migrateGlobal({
global,
locale,
payload,
})
}
}
}
async function migrateGlobal({
global,
locale,
payload,
}: {
global: GlobalConfig
locale: null | string
payload: Payload
}) {
console.log(`SlateToLexical: ${locale}: Migrating global:`, global.slug)
const document = await payload.findGlobal({
slug: global.slug,
depth: 0,
locale: locale || undefined,
overrideAccess: true,
})
const found = migrateDocument({
document,
fields: global.fields,
})
if (found) {
await payload.updateGlobal({
slug: global.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
}
}
async function migrateCollection({
collection,
cur,
locale,
max,
payload,
}: {
collection: CollectionConfig
cur: number
locale: null | string
max: number
payload: Payload
}) {
console.log(
`SlateToLexical: ${locale}: Migrating collection:`,
collection.slug,
'(' + cur + '/' + max + ')',
)
const documentCount = (
await payload.count({
collection: collection.slug,
depth: 0,
locale: locale || undefined,
})
).totalDocs
let page = 1
let migrated = 0
while (migrated < documentCount) {
const documents = await payload.find({
collection: collection.slug,
depth: 0,
locale: locale || undefined,
overrideAccess: true,
page,
pagination: true,
})
for (const document of documents.docs) {
migrated++
console.log(
`SlateToLexical: ${locale}: Migrating collection:`,
collection.slug,
'(' +
cur +
'/' +
max +
') - Migrating Document: ' +
document.id +
' (' +
migrated +
'/' +
documentCount +
')',
)
const found = migrateDocument({
document,
fields: collection.fields,
})
if (found) {
await payload.update({
id: document.id,
collection: collection.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
}
}
page++
}
}
function migrateDocument({
document,
fields,
}: {
document: Record<string, unknown>
fields: Field[]
}): boolean {
return !!migrateDocumentFields({
data: document,
fields,
found: 0,
})
}

View File

@@ -0,0 +1,112 @@
import type { Field } from 'payload'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload/shared'
import type {
SlateNodeConverter,
SlateNodeConverterProvider,
} from '../../features/migrations/slateToLexical/converter/types.js'
import type { LexicalRichTextAdapter } from '../../types.js'
import { convertSlateToLexical } from '../../features/migrations/slateToLexical/converter/index.js'
type NestedRichTextFieldsArgs = {
data: unknown
fields: Field[]
found: number
}
export const migrateDocumentFields = ({
data,
fields,
found,
}: NestedRichTextFieldsArgs): number => {
for (const field of fields) {
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
if (fieldAffectsData(field) && typeof data[field.name] === 'object') {
migrateDocumentFields({
data: data[field.name],
fields: field.fields,
found,
})
} else {
migrateDocumentFields({
data,
found,
fields: field.fields,
})
}
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
migrateDocumentFields({
data,
found,
fields: tab.fields,
})
})
} else if (Array.isArray(data[field.name])) {
if (field.type === 'blocks') {
data[field.name].forEach((row, i) => {
const block = field.blocks.find(({ slug }) => slug === row?.blockType)
if (block) {
migrateDocumentFields({
data: data[field.name][i],
found,
fields: block.fields,
})
}
})
}
if (field.type === 'array') {
data[field.name].forEach((_, i) => {
migrateDocumentFields({
data: data[field.name][i],
found,
fields: field.fields,
})
})
}
}
if (field.type === 'richText' && Array.isArray(data[field.name])) {
// Slate richText
const editor: LexicalRichTextAdapter = field.editor as LexicalRichTextAdapter
if (editor && typeof editor === 'object') {
if ('features' in editor && editor.features?.length) {
// find slatetolexical feature
const slateToLexicalFeature = editor.editorConfig.resolvedFeatureMap.get('slateToLexical')
if (slateToLexicalFeature) {
// DO CONVERSION
const converterProviders = (
slateToLexicalFeature.sanitizedServerFeatureProps as {
converters?: SlateNodeConverterProvider[]
}
).converters
const converters: SlateNodeConverter[] = []
for (const converter of converterProviders) {
converters.push(converter.converter)
}
data[field.name] = convertSlateToLexical({
converters,
slateData: data[field.name],
})
found++
}
}
}
}
}
return found
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.55",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.55",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.55",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.55",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.55",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.0.0-beta.54",
"version": "3.0.0-beta.55",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
'use client'
import { isImage } from 'payload/shared'
import React from 'react'
import { UploadActions } from '../../elements/Upload/index.js'
@@ -13,11 +12,12 @@ const baseClass = 'file-details'
import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload'
export type FileDetailsProps = {
canEdit?: boolean
collectionSlug: string
customUploadActions?: React.ReactNode[]
doc: Data & {
sizes?: FileSizes
}
enableAdjustments?: boolean
handleRemove?: () => void
hasImageSizes?: boolean
imageCacheTag?: string
@@ -25,8 +25,16 @@ export type FileDetailsProps = {
}
export const FileDetails: React.FC<FileDetailsProps> = (props) => {
const { canEdit, collectionSlug, doc, handleRemove, hasImageSizes, imageCacheTag, uploadConfig } =
props
const {
collectionSlug,
customUploadActions,
doc,
enableAdjustments,
handleRemove,
hasImageSizes,
imageCacheTag,
uploadConfig,
} = props
const { id, filename, filesize, height, mimeType, thumbnailURL, url, width } = doc
@@ -52,9 +60,12 @@ export const FileDetails: React.FC<FileDetailsProps> = (props) => {
width={width as number}
/>
{isImage(mimeType as string) && mimeType !== 'image/svg+xml' && (
<UploadActions canEdit={canEdit} showSizePreviews={hasImageSizes && doc.filename} />
)}
<UploadActions
customActions={customUploadActions}
enableAdjustments={enableAdjustments}
enablePreviewSizes={hasImageSizes && doc.filename}
mimeType={mimeType}
/>
</div>
{handleRemove && (
<Button

View File

@@ -48,7 +48,7 @@
background-color: var(--theme-bg);
}
&__file-mutation {
&__upload-actions {
display: flex;
gap: calc(var(--base) / 2);
flex-wrap: wrap;

View File

@@ -33,34 +33,60 @@ const validate = (value) => {
return true
}
export const UploadActions = ({ canEdit, showSizePreviews }) => {
type UploadActionsArgs = {
customActions?: React.ReactNode[]
enableAdjustments: boolean
enablePreviewSizes: boolean
mimeType: string
}
export const UploadActions = ({
customActions,
enableAdjustments,
enablePreviewSizes,
mimeType,
}: UploadActionsArgs) => {
const { t } = useTranslation()
const fileTypeIsAdjustable = isImage(mimeType) && mimeType !== 'image/svg+xml'
if (!fileTypeIsAdjustable && (!customActions || customActions.length === 0)) return null
return (
<div className={`${baseClass}__file-mutation`}>
{showSizePreviews && (
<DrawerToggler className={`${baseClass}__previewSizes`} slug={sizePreviewSlug}>
{t('upload:previewSizes')}
</DrawerToggler>
)}
{canEdit && (
<DrawerToggler className={`${baseClass}__edit`} slug={editDrawerSlug}>
{t('upload:editImage')}
</DrawerToggler>
<div className={`${baseClass}__upload-actions`}>
{fileTypeIsAdjustable && (
<React.Fragment>
{enablePreviewSizes && (
<DrawerToggler className={`${baseClass}__previewSizes`} slug={sizePreviewSlug}>
{t('upload:previewSizes')}
</DrawerToggler>
)}
{enableAdjustments && (
<DrawerToggler className={`${baseClass}__edit`} slug={editDrawerSlug}>
{t('upload:editImage')}
</DrawerToggler>
)}
</React.Fragment>
)}
{customActions &&
customActions.map((CustomAction, i) => {
return <React.Fragment key={i}>{CustomAction}</React.Fragment>
})}
</div>
)
}
export type UploadProps = {
collectionSlug: string
customActions?: React.ReactNode[]
initialState?: FormState
onChange?: (file?: File) => void
updatedAt?: string
uploadConfig: SanitizedCollectionConfig['upload']
}
export const Upload: React.FC<UploadProps> = (props) => {
const { collectionSlug, initialState, onChange, updatedAt, uploadConfig } = props
const { collectionSlug, customActions, initialState, onChange, uploadConfig } = props
const [replacingFile, setReplacingFile] = useState(false)
const [fileSrc, setFileSrc] = useState<null | string>(null)
@@ -75,29 +101,52 @@ export const Upload: React.FC<UploadProps> = (props) => {
})
const [_crop, setCrop] = useState({ x: 0, y: 0 })
const handleFileChange = React.useCallback(
(newFile: File) => {
if (newFile instanceof File) {
const fileReader = new FileReader()
fileReader.onload = (e) => {
const imgSrc = e.target?.result
if (typeof imgSrc === 'string') {
setFileSrc(imgSrc)
}
}
fileReader.readAsDataURL(newFile)
}
setValue(newFile)
if (typeof onChange === 'function') {
onChange(newFile)
}
},
[onChange, setValue],
)
const handleFileNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const updatedFileName = e.target.value
if (value) {
const fileValue = value
// Creating a new File object with updated properties
const newFile = new File([fileValue], updatedFileName, { type: fileValue.type })
setValue(newFile) // Updating the state with the new File object
handleFileChange(newFile)
}
}
const handleFileSelection = React.useCallback(
(files: FileList) => {
const fileToUpload = files?.[0]
setValue(fileToUpload)
handleFileChange(fileToUpload)
},
[setValue],
[handleFileChange],
)
const handleFileRemoval = useCallback(() => {
setReplacingFile(true)
setValue(null)
handleFileChange(null)
setFileSrc('')
}, [setValue])
}, [handleFileChange])
const onEditsSave = React.useCallback(
({ crop, focalPosition }) => {
@@ -128,24 +177,6 @@ export const Upload: React.FC<UploadProps> = (props) => {
setReplacingFile(false)
}, [initialState])
useEffect(() => {
if (value instanceof File) {
const fileReader = new FileReader()
fileReader.onload = (e) => {
const imgSrc = e.target?.result
if (typeof imgSrc === 'string') {
setFileSrc(imgSrc)
}
}
fileReader.readAsDataURL(value)
}
if (typeof onChange === 'function') {
onChange(value)
}
}, [value, onChange, updatedAt])
const canRemoveUpload =
docPermissions?.update?.permission &&
'delete' in docPermissions &&
@@ -165,9 +196,9 @@ export const Upload: React.FC<UploadProps> = (props) => {
<FieldError message={errorMessage} showError={showError} />
{doc.filename && !replacingFile && (
<FileDetails
canEdit={showCrop || showFocalPoint}
collectionSlug={collectionSlug}
doc={doc}
enableAdjustments={showCrop || showFocalPoint}
handleRemove={canRemoveUpload ? handleFileRemoval : undefined}
hasImageSizes={hasImageSizes}
imageCacheTag={doc.updatedAt}
@@ -184,7 +215,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
/>
)}
{value && (
{value && fileSrc && (
<React.Fragment>
<div className={`${baseClass}__thumbnail-wrap`}>
<Thumbnail
@@ -199,13 +230,12 @@ export const Upload: React.FC<UploadProps> = (props) => {
type="text"
value={value.name}
/>
{isImage(value.type) && value.type !== 'image/svg+xml' && (
<UploadActions
canEdit={showCrop || showFocalPoint}
showSizePreviews={hasImageSizes && doc.filename && !replacingFile}
/>
)}
<UploadActions
customActions={customActions}
enableAdjustments={showCrop || showFocalPoint}
enablePreviewSizes={hasImageSizes && doc.filename && !replacingFile}
mimeType={value.type}
/>
</div>
<Button
buttonStyle="icon-label"

View File

@@ -25,6 +25,7 @@ const baseClass = 'upload'
export type UploadInputProps = Omit<UploadFieldProps, 'filterOptions'> & {
api?: string
collection?: ClientCollectionConfig
customUploadActions?: React.ReactNode[]
filterOptions?: FilterOptionsResult
onChange?: (e) => void
relationTo?: UploadField['relationTo']
@@ -41,6 +42,7 @@ export const UploadInput: React.FC<UploadInputProps> = (props) => {
api = '/api',
className,
collection,
customUploadActions,
descriptionProps,
errorProps,
filterOptions,
@@ -147,6 +149,7 @@ export const UploadInput: React.FC<UploadInputProps> = (props) => {
{fileDoc && !missingFile && (
<FileDetails
collectionSlug={relationTo}
customUploadActions={customUploadActions}
doc={fileDoc}
handleRemove={
readOnly

View File

@@ -23,7 +23,9 @@ export type AuthContext<T = ClientUser> = {
refreshPermissions: () => Promise<void>
setPermissions: (permissions: Permissions) => void
setUser: (user: T) => void
strategy?: string
token?: string
tokenExpiration?: number
user?: T | null
}
@@ -36,6 +38,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [user, setUser] = useState<ClientUser | null>()
const [tokenInMemory, setTokenInMemory] = useState<string>()
const [tokenExpiration, setTokenExpiration] = useState<number>()
const [strategy, setStrategy] = useState<string>()
const pathname = usePathname()
const router = useRouter()
// const { code } = useLocale()
@@ -76,6 +79,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const revokeTokenAndExpire = useCallback(() => {
setTokenInMemory(undefined)
setTokenExpiration(undefined)
setStrategy(undefined)
}, [])
const setTokenAndExpiration = useCallback(
@@ -84,6 +88,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (token && json?.exp) {
setTokenInMemory(token)
setTokenExpiration(json.exp)
if (json.strategy) setStrategy(json.strategy)
} else {
revokeTokenAndExpire()
}
@@ -258,6 +263,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
searchParams,
admin,
revokeTokenAndExpire,
strategy,
tokenExpiration,
loginRoute,
])

View File

@@ -102,6 +102,10 @@ export const mapCollections = (args: {
<WithServerSideProps Component={PublishButtonComponent} />
) : undefined
const UploadComponent = collectionConfig?.admin?.components?.edit?.Upload
const Upload = UploadComponent ? <WithServerSideProps Component={UploadComponent} /> : undefined
const beforeList = collectionConfig?.admin?.components?.BeforeList
const BeforeList =
@@ -171,6 +175,7 @@ export const mapCollections = (args: {
PublishButton,
SaveButton,
SaveDraftButton,
Upload,
actionsMap: mapActions({
WithServerSideProps,
collectionConfig,

View File

@@ -112,6 +112,7 @@ export const mapGlobals = ({
PublishButton: PublishButtonComponent,
SaveButton: SaveButtonComponent,
SaveDraftButton: SaveDraftButtonComponent,
Upload: null,
actionsMap: mapActions({
WithServerSideProps,
globalConfig,

View File

@@ -111,6 +111,7 @@ export type ConfigComponentMapBase = {
PublishButton: React.ReactNode
SaveButton: React.ReactNode
SaveDraftButton: React.ReactNode
Upload: React.ReactNode
actionsMap: ActionMap
fieldMap: FieldMap
isPreviewEnabled: boolean

View File

@@ -8,123 +8,124 @@
export interface Config {
collections: {
posts: Post
users: User
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}
posts: Post;
users: User;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
globals: {
menu: Menu
}
locale: null
menu: Menu;
};
locale: null;
user: User & {
collection: 'users'
}
collection: 'users';
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string
text?: string | null
id: string;
text?: string | null;
richText?: {
root: {
type: string
type: string;
children: {
type: string
version: number
[k: string]: unknown
}[]
direction: ('ltr' | 'rtl') | null
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''
indent: number
version: number
}
[k: string]: unknown
} | null
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
richText2?: {
root: {
type: string
type: string;
children: {
type: string
version: number
[k: string]: unknown
}[]
direction: ('ltr' | 'rtl') | null
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''
indent: number
version: number
}
[k: string]: unknown
} | null
updatedAt: string
createdAt: string
_status?: ('draft' | 'published') | null
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string
updatedAt: string
createdAt: string
email: string
resetPasswordToken?: string | null
resetPasswordExpiration?: string | null
salt?: string | null
hash?: string | null
loginAttempts?: number | null
lockUntil?: string | null
password?: string | null
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string
id: string;
user: {
relationTo: 'users'
value: string | User
}
key?: string | null
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null
updatedAt: string
createdAt: string
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string
name?: string | null
batch?: number | null
updatedAt: string
createdAt: string
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "menu".
*/
export interface Menu {
id: string
globalText?: string | null
updatedAt?: string | null
createdAt?: string | null
id: string;
globalText?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -134,6 +134,8 @@ describe('Auth', () => {
const data = await response.json()
expect(data.strategy).toBeDefined()
expect(typeof data.exp).toBe('number')
expect(response.status).toBe(200)
expect(data.user.email).toBeDefined()
})

View File

@@ -1,4 +1,14 @@
export function generateLexicalRichText() {
import type {
SerializedBlockNode,
SerializedParagraphNode,
SerializedTextNode,
SerializedUploadNode,
TypedEditorState,
} from '@payloadcms/richtext-lexical'
export function generateLexicalRichText(): TypedEditorState<
SerializedBlockNode | SerializedParagraphNode | SerializedTextNode | SerializedUploadNode
> {
return {
root: {
type: 'root',
@@ -22,6 +32,7 @@ export function generateLexicalRichText() {
format: '',
indent: 0,
type: 'paragraph',
textFormat: 0,
version: 1,
},
{
@@ -217,6 +228,7 @@ export function generateLexicalRichText() {
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
@@ -247,6 +259,7 @@ export function generateLexicalRichText() {
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
@@ -272,6 +285,7 @@ export function generateLexicalRichText() {
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',

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