Compare commits
24 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95eea694c | ||
|
|
4c6aaafe88 | ||
|
|
dc8c099d9e | ||
|
|
259ae674a1 | ||
|
|
731f023c6d | ||
|
|
86b19d4c74 | ||
|
|
17b8c29799 | ||
|
|
29af2849ba | ||
|
|
a7ac5efd70 | ||
|
|
15c7a9dcf8 | ||
|
|
8e55a2a866 | ||
|
|
0f306da63b | ||
|
|
ba9ea5c752 | ||
|
|
53b7d6f89f | ||
|
|
f5fb095df4 | ||
|
|
721919fae9 | ||
|
|
d3e27e87fe | ||
|
|
e1ff92e8c6 | ||
|
|
ac5d744914 | ||
|
|
b94a265fad | ||
|
|
1ba3a92745 | ||
|
|
9814fd705e | ||
|
|
20455f4fc2 | ||
|
|
9f37bf7397 |
@@ -9,6 +9,7 @@ module.exports = {
|
||||
rules: {
|
||||
'payload/no-jsx-import-statements': 'warn',
|
||||
'payload/no-relative-monorepo-imports': 'error',
|
||||
'payload/no-imports-from-exports-dir': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
13
.github/CODEOWNERS
vendored
13
.github/CODEOWNERS
vendored
@@ -3,22 +3,19 @@
|
||||
### Package Exports ###
|
||||
/**/exports/ @denolfe @jmikrut
|
||||
|
||||
### Adapters ###
|
||||
### Packages ###
|
||||
/packages/richtext-*/ @AlessioGr
|
||||
|
||||
### Plugins ###
|
||||
/packages/plugin-cloud*/ @denolfe
|
||||
/packages/email-*/ @denolfe
|
||||
/packages/storage-*/ @denolfe
|
||||
/packages/create-payload-app/ @denolfe
|
||||
/packages/eslint-*/ @denolfe
|
||||
|
||||
### Templates ###
|
||||
/templates/ @jacobsfletch @denolfe
|
||||
|
||||
### Misc ###
|
||||
/packages/create-payload-app/ @denolfe
|
||||
/packages/eslint-*/ @denolfe
|
||||
|
||||
### Build Files ###
|
||||
/**/package.json @denolfe
|
||||
|
||||
/tsconfig.json @denolfe
|
||||
/**/tsconfig*.json @denolfe
|
||||
|
||||
|
||||
53
.github/workflows/main.yml
vendored
53
.github/workflows/main.yml
vendored
@@ -2,9 +2,14 @@ name: build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
push:
|
||||
branches: ['main', 'beta']
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -49,6 +54,50 @@ jobs:
|
||||
echo "needs_build: ${{ steps.filter.outputs.needs_build }}"
|
||||
echo "templates: ${{ steps.filter.outputs.templates }}"
|
||||
|
||||
lint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# https://github.com/actions/virtual-environments/issues/1187
|
||||
- name: tune linux network
|
||||
run: sudo ethtool -K eth0 tx off rx off
|
||||
|
||||
- name: Setup Node@${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
timeout-minutes: 720
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-
|
||||
pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- run: pnpm install
|
||||
- name: List changed files
|
||||
run: git diff --name-only --diff-filter=d origin/${GITHUB_BASE_REF}...origin/${GITHUB_HEAD_REF}
|
||||
- name: Lint staged
|
||||
run: npx lint-staged --diff="origin/${GITHUB_BASE_REF}...origin/${GITHUB_HEAD_REF}"
|
||||
|
||||
build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.needs_build == 'true' }}
|
||||
|
||||
@@ -57,6 +57,7 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| **`components.RowLabel`** | Function or React component to be rendered as the label on the array row. Receives `({ data, index, path })` as args |
|
||||
| **`isSortable`** | Disable order sorting by setting this value to `false` |
|
||||
|
||||
### Example
|
||||
|
||||
|
||||
@@ -53,9 +53,10 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
In addition to the default [field admin config](/docs/fields/overview#admin-config), you can adjust the following properties:
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | ------------------------------- |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| Option | Description |
|
||||
| ------------------- | ---------------------------------- |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| **`isSortable`** | Disable order sorting by setting this value to `false` |
|
||||
|
||||
### Block configs
|
||||
|
||||
|
||||
@@ -26,28 +26,28 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma
|
||||
|
||||
### Config
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
|
||||
| **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. |
|
||||
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). |
|
||||
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. |
|
||||
| **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) |
|
||||
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
|
||||
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
|
||||
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
|
||||
| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |
|
||||
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |
|
||||
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
|
||||
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
|
||||
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
|
||||
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) |
|
||||
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
|
||||
| **`required`** | Require this field to have a value. |
|
||||
| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. |
|
||||
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
|
||||
| Option | Description |
|
||||
|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
|
||||
| **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. |
|
||||
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). |
|
||||
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. |
|
||||
| **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. |
|
||||
| **`maxDepth`** | Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth) |
|
||||
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
|
||||
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
|
||||
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
|
||||
| **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |
|
||||
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |
|
||||
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
|
||||
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
|
||||
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
|
||||
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) |
|
||||
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
|
||||
| **`required`** | Require this field to have a value. |
|
||||
| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. |
|
||||
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
|
||||
@@ -156,6 +156,28 @@ To populate `user.author.department` in it's entirety you could specify `?depth=
|
||||
}
|
||||
```
|
||||
|
||||
#### Field-level max depth
|
||||
|
||||
Fields like relationships or uploads can have a `maxDepth` property that limits the depth of the population for that field. Here are some examples:
|
||||
|
||||
Depth: 10
|
||||
Current depth when field is accessed: 1
|
||||
`maxDepth`: undefined
|
||||
|
||||
In this case, the field would be populated to 9 levels of population.
|
||||
|
||||
Depth: 10
|
||||
Current depth when field is accessed: 0
|
||||
`maxDepth`: 2
|
||||
|
||||
In this case, the field would be populated to 2 levels of population, despite there being a remaining depth of 8.
|
||||
|
||||
Depth: 10
|
||||
Current depth when field is accessed: 2
|
||||
`maxDepth`: 1
|
||||
|
||||
In this case, the field would not be populated, as the current depth (2) has exceeded the `maxDepth` for this field (1).
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
|
||||
284
docs/live-preview/client.mdx
Normal file
284
docs/live-preview/client.mdx
Normal file
@@ -0,0 +1,284 @@
|
||||
---
|
||||
title: Client-side Live Preview
|
||||
label: Client-side
|
||||
order: 40
|
||||
desc: Learn how to implement Live Preview in your client-side front-end application.
|
||||
keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview
|
||||
---
|
||||
|
||||
<Banner type="info">
|
||||
If your front-end application is supports Server Components like the [Next.js App Router](https://nextjs.org/docs/app), etc., we suggest setting up [server-side Live Preview](./server).
|
||||
</Banner>
|
||||
|
||||
While using Live Preview, the Admin panel emits a new `window.postMessage` event every time your document has changed. Your front-end application can listen for these events and re-render accordingly.
|
||||
|
||||
If your front-end application is built with [React](#react) or [Vue](#vue), use the `useLivePreview` hooks that Payload provides. In the future, all other major frameworks like Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own hook](#building-your-own-hook) for more information.
|
||||
|
||||
By default, all hooks accept the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------- |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
| **`apiRoute`** | The path of your API route as defined in `routes.api`. Defaults to `/api`. |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
And return the following values:
|
||||
|
||||
| Path | Description |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **`data`** | The live data of the document, merged with the initial data. |
|
||||
| **`isLoading`** | A boolean that indicates whether or not the document is loading. |
|
||||
|
||||
<Banner type="info">
|
||||
If your front-end is tightly coupled to required fields, you should ensure that your UI does not
|
||||
break when these fields are removed. For example, if you are rendering something like
|
||||
`data.relatedPosts[0].title`, your page will break once you remove the first related post. To get
|
||||
around this, use conditional logic, optional chaining, or default values in your UI where needed.
|
||||
For example, `data?.relatedPosts?.[0]?.title`.
|
||||
</Banner>
|
||||
|
||||
<Banner type="info">
|
||||
It is important that the `depth` argument matches exactly with the depth of your initial page request. The depth property is used to populated relationships and uploads beyond their IDs. See [Depth](../getting-started/concepts#depth) for more information.
|
||||
</Banner>
|
||||
|
||||
### React
|
||||
|
||||
If your front-end application is built with client-side [React](https://react.dev) like [Next.js Pages Router](https://nextjs.org/docs/pages), you can use the `useLivePreview` hook that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-react` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-react
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your React component:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useLivePreview } from '@payloadcms/live-preview-react'
|
||||
import { Page as PageType } from '@/payload-types'
|
||||
|
||||
// Fetch the page in a server component, pass it to the client component, then thread it through the hook
|
||||
// The hook will take over from there and keep the preview in sync with the changes you make
|
||||
// The `data` property will contain the live data of the document
|
||||
export const PageClient: React.FC<{
|
||||
page: {
|
||||
title: string
|
||||
}
|
||||
}> = ({ page: initialPage }) => {
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 2,
|
||||
})
|
||||
|
||||
return <h1>{data.title}</h1>
|
||||
}
|
||||
```
|
||||
|
||||
### Vue
|
||||
|
||||
If your front-end application is built with [Vue 3](https://vuejs.org) or [Nuxt 3](https://nuxt.js), you can use the `useLivePreview` composable that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-vue` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-vue
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your Vue component:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { PageData } from '~/types';
|
||||
import { defineProps } from 'vue';
|
||||
import { useLivePreview } from '@payloadcms/live-preview-vue';
|
||||
|
||||
// Fetch the initial data on the parent component or using async state
|
||||
const props = defineProps<{ initialData: PageData }>();
|
||||
|
||||
// The hook will take over from here and keep the preview in sync with the changes you make.
|
||||
// The `data` property will contain the live data of the document only when viewed from the Preview view of the Admin UI.
|
||||
const { data } = useLivePreview<PageData>({
|
||||
initialData: props.initialData,
|
||||
serverURL: "<PAYLOAD_SERVER_URL>",
|
||||
depth: 2,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ data.title }}</h1>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Building your own hook
|
||||
|
||||
No matter what front-end framework you are using, you can build your own hook using the same underlying tooling that Payload provides.
|
||||
|
||||
First, install the base `@payloadcms/live-preview` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview
|
||||
```
|
||||
|
||||
This package provides the following functions:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`subscribe`** | Subscribes to the Admin panel's `window.postMessage` events and calls the provided callback function. |
|
||||
| **`unsubscribe`** | Unsubscribes from the Admin panel's `window.postMessage` events. |
|
||||
| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. |
|
||||
| **`isLivePreviewEvent`** | Checks if a `MessageEvent` originates from the Admin panel and is a Live Preview event, i.e. debounced form state. |
|
||||
|
||||
The `subscribe` function takes the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **`callback`** \* | A callback function that is called with `data` every time a change is made to the document. |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
|
||||
With these functions, you can build your own hook using your front-end framework of choice:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe } from '@payloadcms/live-preview'
|
||||
|
||||
// To build your own hook, subscribe to Live Preview events using the `subscribe` function
|
||||
// It handles everything from:
|
||||
// 1. Listening to `window.postMessage` events
|
||||
// 2. Merging initial data with active form state
|
||||
// 3. Populating relationships and uploads
|
||||
// 4. Calling the `onChange` callback with the result
|
||||
// Your hook should also:
|
||||
// 1. Tell the Admin panel when it is ready to receive messages
|
||||
// 2. Handle the results of the `onChange` callback to update the UI
|
||||
// 3. Unsubscribe from the `window.postMessage` events when it unmounts
|
||||
```
|
||||
|
||||
Here is an example of what the same `useLivePreview` React hook from above looks like under the hood:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
|
||||
export const useLivePreview = <T extends any>(props: {
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): {
|
||||
data: T
|
||||
isLoading: boolean
|
||||
} => {
|
||||
const { depth = 0, initialData, serverURL } = props
|
||||
const [data, setData] = useState<T>(initialData)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onChange = useCallback((mergedData) => {
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
// Set this merged data into state so that React will re-render the UI
|
||||
setData(mergedData)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for `window.postMessage` events from the Admin panel
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
const subscription = subscribe({
|
||||
callback: onChange,
|
||||
depth,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
// Once subscribed, send a `ready` message back up to the Admin panel
|
||||
// This will indicate that the front-end is ready to receive messages
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
// When the component unmounts, unsubscribe from the `window.postMessage` events
|
||||
return () => {
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
}, [serverURL, onChange, depth, initialData])
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Banner type="info">
|
||||
When building your own hook, ensure that the args and return values are consistent with the ones
|
||||
listed at the top of this document. This will ensure that all hooks follow the same API.
|
||||
</Banner>
|
||||
|
||||
## Example
|
||||
|
||||
For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview/payload). There you will find examples of various front-end frameworks and how to integrate each one of them, including:
|
||||
|
||||
- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app)
|
||||
- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Relationships and/or uploads are not populating
|
||||
|
||||
If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example:
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
{
|
||||
// ...
|
||||
// If your site is running on a different domain than your Payload server,
|
||||
// This will allows requests to be made between the two domains
|
||||
cors: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
// If you are protecting resources behind user authentication,
|
||||
// This will allow cookies to be sent between the two domains
|
||||
csrf: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Relationships and/or uploads disappear after editing a document
|
||||
|
||||
It is possible that either you are setting an improper [`depth`](../getting-started/concepts#depth) in your initial request and/or your `useLivePreview` hook, or they're mismatched. Ensure that the `depth` parameter is set to the correct value, and that it matches exactly in both places. For example:
|
||||
|
||||
```tsx
|
||||
// Your initial request
|
||||
const { docs } = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1, // Ensure this is set to the proper depth for your application
|
||||
where: {
|
||||
slug: {
|
||||
equals: 'home',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Your hook
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 1, // Ensure this matches the depth of your initial request
|
||||
})
|
||||
```
|
||||
@@ -1,279 +1,16 @@
|
||||
---
|
||||
title: Implementing Live Preview in your app
|
||||
label: Frontend Implementation
|
||||
title: Implementing Live Preview in your frontend
|
||||
label: Frontend
|
||||
order: 20
|
||||
desc: Learn how to implement Live Preview in your front-end application.
|
||||
keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview
|
||||
---
|
||||
|
||||
While using Live Preview, the Admin panel emits a new `window.postMessage` event every time a change is made to the document. Your front-end application can listen for these events and re-render accordingly.
|
||||
There are two ways to use Live Preview in your own application depending on whether your front-end framework supports server components:
|
||||
|
||||
Wiring your front-end into Live Preview is easy. If your front-end application is built with React, Next.js, Vue or Nuxt.js, use the `useLivePreview` hook that Payload provides. In the future, all other major frameworks like Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own hook](#building-your-own-hook) for more information.
|
||||
|
||||
By default, all hooks accept the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------- |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
| **`apiRoute`** | The path of your API route as defined in `routes.api`. Defaults to `/api`. |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
And return the following values:
|
||||
|
||||
| Path | Description |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **`data`** | The live data of the document, merged with the initial data. |
|
||||
| **`isLoading`** | A boolean that indicates whether or not the document is loading. |
|
||||
- [Server-side Live Preview (suggested)](./server)
|
||||
- [Client-side Live Preview](./client)
|
||||
|
||||
<Banner type="info">
|
||||
If your front-end is tightly coupled to required fields, you should ensure that your UI does not
|
||||
break when these fields are removed. For example, if you are rendering something like
|
||||
`data.relatedPosts[0].title`, your page will break once you remove the first related post. To get
|
||||
around this, use conditional logic, optional chaining, or default values in your UI where needed.
|
||||
For example, `data?.relatedPosts?.[0]?.title`.
|
||||
We suggest using server-side Live Preview if your framework supports it, it is both simpler to setup and more performant to run than the client-side alternative.
|
||||
</Banner>
|
||||
|
||||
<Banner type="info">
|
||||
It is important that the `depth` argument matches exactly with the depth of your initial page request. The depth property is used to populated relationships and uploads beyond their IDs. See [Depth](../getting-started/concepts#depth) for more information.
|
||||
</Banner>
|
||||
|
||||
### React
|
||||
|
||||
If your front-end application is built with React or Next.js, you can use the `useLivePreview` hook that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-react` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-react
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your React component:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useLivePreview } from '@payloadcms/live-preview-react'
|
||||
import { Page as PageType } from '@/payload-types'
|
||||
|
||||
// Fetch the page in a server component, pass it to the client component, then thread it through the hook
|
||||
// The hook will take over from there and keep the preview in sync with the changes you make
|
||||
// The `data` property will contain the live data of the document
|
||||
export const PageClient: React.FC<{
|
||||
page: {
|
||||
title: string
|
||||
}
|
||||
}> = ({ page: initialPage }) => {
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 2,
|
||||
})
|
||||
|
||||
return <h1>{data.title}</h1>
|
||||
}
|
||||
```
|
||||
|
||||
### Vue
|
||||
|
||||
If your front-end application is built with Vue 3 or Nuxt 3, you can use the `useLivePreview` composable that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-vue` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-vue
|
||||
```
|
||||
|
||||
Then, use the `useLivePreview` hook in your Vue component:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { PageData } from '~/types';
|
||||
import { defineProps } from 'vue';
|
||||
import { useLivePreview } from '@payloadcms/live-preview-vue';
|
||||
|
||||
// Fetch the initial data on the parent component or using async state
|
||||
const props = defineProps<{ initialData: PageData }>();
|
||||
|
||||
// The hook will take over from here and keep the preview in sync with the changes you make.
|
||||
// The `data` property will contain the live data of the document only when viewed from the Preview view of the Admin UI.
|
||||
const { data } = useLivePreview<PageData>({
|
||||
initialData: props.initialData,
|
||||
serverURL: "<PAYLOAD_SERVER_URL>",
|
||||
depth: 2,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ data.title }}</h1>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Building your own hook
|
||||
|
||||
No matter what front-end framework you are using, you can build your own hook using the same underlying tooling that Payload provides.
|
||||
|
||||
First, install the base `@payloadcms/live-preview` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview
|
||||
```
|
||||
|
||||
This package provides the following functions:
|
||||
|
||||
| Path | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`subscribe`** | Subscribes to the Admin panel's `window.postMessage` events and calls the provided callback function. |
|
||||
| **`unsubscribe`** | Unsubscribes from the Admin panel's `window.postMessage` events. |
|
||||
| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. |
|
||||
|
||||
The `subscribe` function takes the following args:
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **`callback`** \* | A callback function that is called with `data` every time a change is made to the document. |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
|
||||
With these functions, you can build your own hook using your front-end framework of choice:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe } from '@payloadcms/live-preview'
|
||||
|
||||
// To build your own hook, subscribe to Live Preview events using the`subscribe` function
|
||||
// It handles everything from:
|
||||
// 1. Listening to `window.postMessage` events
|
||||
// 2. Merging initial data with active form state
|
||||
// 3. Populating relationships and uploads
|
||||
// 4. Calling the `onChange` callback with the result
|
||||
// Your hook should also:
|
||||
// 1. Tell the Admin panel when it is ready to receive messages
|
||||
// 2. Handle the results of the `onChange` callback to update the UI
|
||||
// 3. Unsubscribe from the `window.postMessage` events when it unmounts
|
||||
```
|
||||
|
||||
Here is an example of what the same `useLivePreview` React hook from above looks like under the hood:
|
||||
|
||||
```tsx
|
||||
import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
|
||||
export const useLivePreview = <T extends any>(props: {
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): {
|
||||
data: T
|
||||
isLoading: boolean
|
||||
} => {
|
||||
const { depth = 0, initialData, serverURL } = props
|
||||
const [data, setData] = useState<T>(initialData)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onChange = useCallback((mergedData) => {
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
// Set this merged data into state so that React will re-render the UI
|
||||
setData(mergedData)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for `window.postMessage` events from the Admin panel
|
||||
// When a change is made, the `onChange` callback will be called with the merged data
|
||||
const subscription = subscribe({
|
||||
callback: onChange,
|
||||
depth,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
// Once subscribed, send a `ready` message back up to the Admin panel
|
||||
// This will indicate that the front-end is ready to receive messages
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
// When the component unmounts, unsubscribe from the `window.postMessage` events
|
||||
return () => {
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
}, [serverURL, onChange, depth, initialData])
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Banner type="info">
|
||||
When building your own hook, ensure that the args and return values are consistent with the ones
|
||||
listed at the top of this document. This will ensure that all hooks follow the same API.
|
||||
</Banner>
|
||||
|
||||
## Example
|
||||
|
||||
For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview/payload). There you will find examples of various front-end frameworks and how to integrate each one of them, including:
|
||||
|
||||
- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app)
|
||||
- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Relationships and/or uploads are not populating
|
||||
|
||||
If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example:
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
{
|
||||
// ...
|
||||
// If your site is running on a different domain than your Payload server,
|
||||
// This will allows requests to be made between the two domains
|
||||
cors: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
// If you are protecting resources behind user authentication,
|
||||
// This will allow cookies to be sent between the two domains
|
||||
csrf: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Relationships and/or uploads disappear after editing a document
|
||||
|
||||
It is possible that either you are setting an improper [`depth`](../getting-started/concepts#depth) in your initial request and/or your `useLivePreview` hook, or they're mismatched. Ensure that the `depth` parameter is set to the correct value, and that it matches exactly in both places. For example:
|
||||
|
||||
```tsx
|
||||
// Your initial request
|
||||
const { docs } = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1, // Ensure this is set to the proper depth for your application
|
||||
where: {
|
||||
slug: {
|
||||
equals: 'home',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Your hook
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 1, // Ensure this matches the depth of your initial request
|
||||
})
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ keywords: live preview, preview, live, iframe, iframe preview, visual editing, d
|
||||
|
||||
**With Live Preview you can render your front-end application directly within the Admin panel. As you type, your changes take effect in real-time. No need to save a draft or publish your changes.**
|
||||
|
||||
Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through [`window.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives.
|
||||
Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through [`window.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives. Live Preview works in both server-side as well as client-side environments. See [Front-End](./frontend) for more details.
|
||||
|
||||
{/* IMAGE OF LIVE PREVIEW HERE */}
|
||||
|
||||
|
||||
174
docs/live-preview/server.mdx
Normal file
174
docs/live-preview/server.mdx
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Server-side Live Preview
|
||||
label: Server-side
|
||||
order: 30
|
||||
desc: Learn how to implement Live Preview in your server-side front-end application.
|
||||
keywords: live preview, frontend, react, next.js, vue, nuxt.js, svelte, hook, useLivePreview
|
||||
---
|
||||
|
||||
<Banner type="info">
|
||||
Server-side Live Preview is only for front-end frameworks that support the concept of Server Components, i.e. [React Server Components](https://react.dev/reference/rsc/server-components). If your front-end application is built with a client-side framework like the [Next.js Pages Router](https://nextjs.org/docs/pages), [React Router](https://reactrouter.com), [Vue 3](https://vuejs.org), etc., see [client-side Live Preview](./client).
|
||||
</Banner>
|
||||
|
||||
Server-side Live Preview works by making a roundtrip to the server every time your document is saved, i.e. draft save, autosave, or publish. While using Live Preview, the Admin panel emits a new `window.postMessage` event which your front-end application can use to invoke this process. In Next.js, this means simply calling `router.refresh()` which will hydrate the HTML using new data straight from the [Local API](../local-api/overview).
|
||||
|
||||
<Banner type="warning">
|
||||
It is recommended that you enable [Autosave](../versions/autosave) alongside Live Preview to make the experience feel more responsive.
|
||||
</Banner>
|
||||
|
||||
If your front-end application is built with [React](#react), you can use the `RefreshRouteOnChange` function that Payload provides and give it your own router refresh function. In the future, all other major frameworks like Vue and Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own router refresh component](#building-your-own-router-refresh-component) for more information.
|
||||
|
||||
### React
|
||||
|
||||
If your front-end application is built with [React](https://react.dev) or [Next.js](https://nextjs.org), you can use the `RefreshRouteOnSave` component that Payload provides.
|
||||
|
||||
First, install the `@payloadcms/live-preview-react` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview-react
|
||||
```
|
||||
|
||||
Then, render `RefreshRouteOnSave` anywhere in your `page.tsx`. Here's an example:
|
||||
|
||||
`page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { RefreshRouteOnSave } from './RefreshRouteOnSave.tsx'
|
||||
import { getPayloadHMR } from '@payloadcms/next'
|
||||
import config from '../payload.config'
|
||||
|
||||
export default async function Page() {
|
||||
const payload = await getPayloadHMR({ config })
|
||||
const page = await payload.find({
|
||||
collection: 'pages',
|
||||
draft: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<RefreshRouteOnSave />
|
||||
<h1>Hello, world!</h1>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`RefreshRouteOnSave.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
|
||||
import { useRouter } from 'next/navigation.js'
|
||||
import React from 'react'
|
||||
|
||||
export const RefreshRouteOnSave: React.FC = () => {
|
||||
const router = useRouter()
|
||||
return <PayloadLivePreview refresh={router.refresh} serverURL={process.env.PAYLOAD_SERVER_URL} />
|
||||
}
|
||||
```
|
||||
|
||||
## Building your own router refresh component
|
||||
|
||||
No matter what front-end framework you are using, you can build your own component using the same underlying tooling that Payload provides.
|
||||
|
||||
First, install the base `@payloadcms/live-preview` package:
|
||||
|
||||
```bash
|
||||
npm install @payloadcms/live-preview
|
||||
```
|
||||
|
||||
This package provides the following functions:
|
||||
|
||||
| Path | Description |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. |
|
||||
| **`isDocumentEvent`** | Checks if a `MessageEvent` originates from the Admin panel and is a document-level event, i.e. draft save, autosave, publish, etc. |
|
||||
|
||||
With these functions, you can build your own hook using your front-end framework of choice:
|
||||
|
||||
```tsx
|
||||
import { ready, isDocumentEvent } from '@payloadcms/live-preview'
|
||||
|
||||
// To build your own component:
|
||||
// 1. Listen for document-level `window.postMessage` events sent from the Admin panel
|
||||
// 2. Tell the Admin panel when it is ready to receive messages
|
||||
// 3. Refresh the route every time a new document-level event is received
|
||||
// 4. Unsubscribe from the `window.postMessage` events when it unmounts
|
||||
```
|
||||
|
||||
Here is an example of what the same `RefreshRouteOnSave` React component from above looks like under the hood:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
|
||||
import { isDocumentEvent, ready } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export const RefreshRouteOnSave: React.FC<{
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
refresh: () => void
|
||||
serverURL: string
|
||||
}> = (props) => {
|
||||
const { apiRoute, depth, refresh, serverURL } = props
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
if (isDocumentEvent(event, serverURL)) {
|
||||
if (typeof refresh === 'function') {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
},
|
||||
[refresh, serverURL],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', onMessage)
|
||||
}
|
||||
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('message', onMessage)
|
||||
}
|
||||
}
|
||||
}, [serverURL, onMessage, depth, apiRoute])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
{/* TODO: add example once beta has been release and an example can be created */}
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Updates do not appear as fast as client-side Live Preview
|
||||
|
||||
If you are noticing that updates feel less snappy than client-side Live Preview (i.e. the `useLivePreview` hook), this is because of how the two differ in how they work—instead of emitting events against form state as you type, server-side Live Preview refreshes the route after a new document is saved. You can use autosave to mimic this effect. Try decreasing the value of `versions.autoSave.interval` to make the experience feel more responsive:
|
||||
|
||||
```ts
|
||||
// collection.ts
|
||||
{
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -85,7 +85,7 @@ The following custom endpoints are automatically opened for you:
|
||||
|
||||
##### Stripe REST Proxy
|
||||
|
||||
If `rest` is true, proxies the [Stripe REST API](https://stripe.com/docs/api) behind [Payload access control](https://payloadcms.com/docs/access-control/overview) and returns the result. If you need to proxy the API server-side, use the [stripeProxy](#node) function.
|
||||
If `rest` is true, proxies the [Stripe REST API](https://stripe.com/docs/api) behind [Payload access control](https://payloadcms.com/docs/access-control/overview) and returns the result. This flag should only be used for local development, see the security note below for more information.
|
||||
|
||||
```ts
|
||||
const res = await fetch(`/api/stripe/rest`, {
|
||||
@@ -106,6 +106,8 @@ const res = await fetch(`/api/stripe/rest`, {
|
||||
})
|
||||
```
|
||||
|
||||
If you need to proxy the API server-side, use the [stripeProxy](#node) function.
|
||||
|
||||
<Banner type="info">
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
@@ -113,6 +115,12 @@ const res = await fetch(`/api/stripe/rest`, {
|
||||
config.
|
||||
</Banner>
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Warning:</strong>
|
||||
<br />
|
||||
Opening the REST proxy endpoint in production is a potential security risk. Authenticated users will have open access to the Stripe REST API. In production, open your own endpoint and use the [stripeProxy](#node) function to proxy the Stripe API server-side.
|
||||
</Banner>
|
||||
|
||||
## Webhooks
|
||||
|
||||
[Stripe webhooks](https://stripe.com/docs/webhooks) are used to sync from Stripe to Payload. Webhooks listen for events on your Stripe account so you can trigger reactions to them. Follow the steps below to enable webhooks.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-payload-app",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-mongodb",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The officially supported MongoDB database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-nodemailer",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "Payload Nodemailer Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-resend",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "Payload Resend Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow imports from an exports directory',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create: function (context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value
|
||||
|
||||
// Match imports starting with any number of "../" followed by "exports/"
|
||||
const regex = /^(\.?\.\/)*exports\//
|
||||
|
||||
if (regex.test(importPath)) {
|
||||
context.report({
|
||||
node: node.source,
|
||||
message:
|
||||
'Import from relative "exports/" is not allowed. Import directly to the source instead.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,7 @@ module.exports = {
|
||||
'no-jsx-import-statements': require('./customRules/no-jsx-import-statements'),
|
||||
'no-non-retryable-assertions': require('./customRules/no-non-retryable-assertions'),
|
||||
'no-relative-monorepo-imports': require('./customRules/no-relative-monorepo-imports'),
|
||||
'no-imports-from-exports-dir': require('./customRules/no-imports-from-exports-dir'),
|
||||
'no-flaky-assertions': require('./customRules/no-flaky-assertions'),
|
||||
'no-wait-function': {
|
||||
create: function (context) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/graphql",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -490,9 +490,12 @@ function buildObjectType({
|
||||
if (editor?.populationPromises) {
|
||||
const fieldPromises = []
|
||||
const populationPromises = []
|
||||
const populateDepth =
|
||||
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
|
||||
|
||||
editor?.populationPromises({
|
||||
context,
|
||||
depth,
|
||||
depth: populateDepth,
|
||||
draft: args.draft,
|
||||
field,
|
||||
fieldPromises,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-react",
|
||||
"version": "0.2.0",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The official live preview React SDK for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
52
packages/live-preview-react/src/RefreshRouteOnSave.tsx
Normal file
52
packages/live-preview-react/src/RefreshRouteOnSave.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
|
||||
import { isDocumentEvent, ready } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export const RefreshRouteOnSave: React.FC<{
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
refresh: () => void
|
||||
serverURL: string
|
||||
}> = (props) => {
|
||||
const { apiRoute, depth, refresh, serverURL } = props
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
if (isDocumentEvent(event, serverURL)) {
|
||||
if (typeof refresh === 'function') {
|
||||
refresh()
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('You must provide a refresh function to `RefreshRouteOnSave`')
|
||||
}
|
||||
}
|
||||
},
|
||||
[refresh, serverURL],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', onMessage)
|
||||
}
|
||||
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('message', onMessage)
|
||||
}
|
||||
}
|
||||
}, [serverURL, onMessage, depth, apiRoute])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,54 +1,2 @@
|
||||
import { ready, subscribe, unsubscribe } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// To prevent the flicker of missing data on initial load,
|
||||
// you can pass in the initial page data from the server
|
||||
// To prevent the flicker of stale data while the post message is being sent,
|
||||
// you can conditionally render loading UI based on the `isLoading` state
|
||||
|
||||
export const useLivePreview = <T extends any>(props: {
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): {
|
||||
data: T
|
||||
isLoading: boolean
|
||||
} => {
|
||||
const { apiRoute, depth, initialData, serverURL } = props
|
||||
const [data, setData] = useState<T>(initialData)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onChange = useCallback((mergedData) => {
|
||||
setData(mergedData)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = subscribe({
|
||||
apiRoute,
|
||||
callback: onChange,
|
||||
depth,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
}, [serverURL, onChange, depth, initialData, apiRoute])
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
export { RefreshRouteOnSave } from './RefreshRouteOnSave.js'
|
||||
export { useLivePreview } from './useLivePreview.js'
|
||||
|
||||
55
packages/live-preview-react/src/useLivePreview.ts
Normal file
55
packages/live-preview-react/src/useLivePreview.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
import { ready, subscribe, unsubscribe } from '@payloadcms/live-preview'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// To prevent the flicker of missing data on initial load,
|
||||
// you can pass in the initial page data from the server
|
||||
// To prevent the flicker of stale data while the post message is being sent,
|
||||
// you can conditionally render loading UI based on the `isLoading` state
|
||||
|
||||
export const useLivePreview = <T extends any>(props: {
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): {
|
||||
data: T
|
||||
isLoading: boolean
|
||||
} => {
|
||||
const { apiRoute, depth, initialData, serverURL } = props
|
||||
const [data, setData] = useState<T>(initialData)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
|
||||
const onChange = useCallback((mergedData) => {
|
||||
setData(mergedData)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = subscribe({
|
||||
apiRoute,
|
||||
callback: onChange,
|
||||
depth,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
if (!hasSentReadyMessage.current) {
|
||||
hasSentReadyMessage.current = true
|
||||
|
||||
ready({
|
||||
serverURL,
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
}, [serverURL, onChange, depth, initialData, apiRoute])
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview",
|
||||
"version": "0.2.2",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The official live preview JavaScript SDK for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
8
packages/live-preview/src/handleMessage.d.ts
vendored
8
packages/live-preview/src/handleMessage.d.ts
vendored
@@ -1,8 +0,0 @@
|
||||
export declare const handleMessage: <T>(args: {
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
event: MessageEvent
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}) => Promise<T>
|
||||
//# sourceMappingURL=handleMessage.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"handleMessage.d.ts","sourceRoot":"","sources":["handleMessage.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,aAAa;eACb,MAAM;YACT,MAAM;WACP,YAAY;;eAER,MAAM;gBAyClB,CAAA"}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isLivePreviewEvent } from './isLivePreviewEvent.js'
|
||||
import { mergeData } from './mergeData.js'
|
||||
|
||||
// For performance reasons, `fieldSchemaJSON` will only be sent once on the initial message
|
||||
@@ -19,12 +20,7 @@ export const handleMessage = async <T>(args: {
|
||||
}): Promise<T> => {
|
||||
const { apiRoute, depth, event, initialData, serverURL } = args
|
||||
|
||||
if (
|
||||
event.origin === serverURL &&
|
||||
event.data &&
|
||||
typeof event.data === 'object' &&
|
||||
event.data.type === 'payload-live-preview'
|
||||
) {
|
||||
if (isLivePreviewEvent(event, serverURL)) {
|
||||
const { data, externallyUpdatedRelationship, fieldSchemaJSON } = event.data
|
||||
|
||||
if (!payloadLivePreviewFieldSchema && fieldSchemaJSON) {
|
||||
|
||||
6
packages/live-preview/src/index.d.ts
vendored
6
packages/live-preview/src/index.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
export { handleMessage } from './handleMessage.js'
|
||||
export { mergeData } from './mergeData.js'
|
||||
export { ready } from './ready.js'
|
||||
export { subscribe } from './subscribe.js'
|
||||
export { unsubscribe } from './unsubscribe.js'
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA"}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { handleMessage } from './handleMessage.js'
|
||||
export { isDocumentEvent } from './isDocumentEvent.js'
|
||||
export { isLivePreviewEvent } from './isLivePreviewEvent.js'
|
||||
export { mergeData } from './mergeData.js'
|
||||
export { ready } from './ready.js'
|
||||
export { subscribe } from './subscribe.js'
|
||||
|
||||
5
packages/live-preview/src/isDocumentEvent.ts
Normal file
5
packages/live-preview/src/isDocumentEvent.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const isDocumentEvent = (event: MessageEvent, serverURL: string): boolean =>
|
||||
event.origin === serverURL &&
|
||||
event.data &&
|
||||
typeof event.data === 'object' &&
|
||||
event.data.type === 'payload-document-event'
|
||||
5
packages/live-preview/src/isLivePreviewEvent.ts
Normal file
5
packages/live-preview/src/isLivePreviewEvent.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const isLivePreviewEvent = (event: MessageEvent, serverURL: string): boolean =>
|
||||
event.origin === serverURL &&
|
||||
event.data &&
|
||||
typeof event.data === 'object' &&
|
||||
event.data.type === 'payload-live-preview'
|
||||
26
packages/live-preview/src/mergeData.d.ts
vendored
26
packages/live-preview/src/mergeData.d.ts
vendored
@@ -1,26 +0,0 @@
|
||||
import type { fieldSchemaToJSON } from 'payload/utilities'
|
||||
import type { UpdatedDocument } from './types.js'
|
||||
export declare const mergeData: <T>(args: {
|
||||
apiRoute?: string
|
||||
collectionPopulationRequestHandler?: ({
|
||||
apiPath,
|
||||
endpoint,
|
||||
serverURL,
|
||||
}: {
|
||||
apiPath: string
|
||||
endpoint: string
|
||||
serverURL: string
|
||||
}) => Promise<Response>
|
||||
depth?: number
|
||||
externallyUpdatedRelationship?: UpdatedDocument
|
||||
fieldSchema: ReturnType<typeof fieldSchemaToJSON>
|
||||
incomingData: Partial<T>
|
||||
initialData: T
|
||||
returnNumberOfRequests?: boolean
|
||||
serverURL: string
|
||||
}) => Promise<
|
||||
T & {
|
||||
_numberOfRequests?: number
|
||||
}
|
||||
>
|
||||
//# sourceMappingURL=mergeData.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"mergeData.d.ts","sourceRoot":"","sources":["mergeData.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAE1D,OAAO,KAAK,EAA2B,eAAe,EAAE,MAAM,YAAY,CAAA;AAc1E,eAAO,MAAM,SAAS;eACT,MAAM;;iBAMN,MAAM;kBACL,MAAM;mBACL,MAAM;UACb,QAAQ,QAAQ,CAAC;YACf,MAAM;oCACkB,eAAe;iBAClC,WAAW,wBAAwB,CAAC;;;6BAGxB,OAAO;eACrB,MAAM;;wBAGK,MAAM;EA6D7B,CAAA"}
|
||||
8
packages/live-preview/src/subscribe.d.ts
vendored
8
packages/live-preview/src/subscribe.d.ts
vendored
@@ -1,8 +0,0 @@
|
||||
export declare const subscribe: <T>(args: {
|
||||
apiRoute?: string
|
||||
callback: (data: T) => void
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}) => (event: MessageEvent) => void
|
||||
//# sourceMappingURL=subscribe.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"subscribe.d.ts","sourceRoot":"","sources":["subscribe.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,SAAS;eACT,MAAM;2BACM,IAAI;YACnB,MAAM;;eAEH,MAAM;cACN,YAAY,KAAK,IAa7B,CAAA"}
|
||||
@@ -10,7 +10,14 @@ export const subscribe = <T>(args: {
|
||||
const { apiRoute, callback, depth, initialData, serverURL } = args
|
||||
|
||||
const onMessage = async (event: MessageEvent) => {
|
||||
const mergedData = await handleMessage<T>({ apiRoute, depth, event, initialData, serverURL })
|
||||
const mergedData = await handleMessage<T>({
|
||||
apiRoute,
|
||||
depth,
|
||||
event,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
callback(mergedData)
|
||||
}
|
||||
|
||||
|
||||
10
packages/live-preview/src/traverseFields.d.ts
vendored
10
packages/live-preview/src/traverseFields.d.ts
vendored
@@ -1,10 +0,0 @@
|
||||
import type { fieldSchemaToJSON } from 'payload/utilities'
|
||||
import type { PopulationsByCollection, UpdatedDocument } from './types.js'
|
||||
export declare const traverseFields: <T>(args: {
|
||||
externallyUpdatedRelationship?: UpdatedDocument
|
||||
fieldSchema: ReturnType<typeof fieldSchemaToJSON>
|
||||
incomingData: T
|
||||
populationsByCollection: PopulationsByCollection
|
||||
result: T
|
||||
}) => void
|
||||
//# sourceMappingURL=traverseFields.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"traverseFields.d.ts","sourceRoot":"","sources":["traverseFields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAE1D,OAAO,KAAK,EAAE,uBAAuB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAI1E,eAAO,MAAM,cAAc;oCACO,eAAe;iBAClC,WAAW,wBAAwB,CAAC;;6BAExB,uBAAuB;;MAE9C,IA4QH,CAAA"}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/next",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -3,6 +3,7 @@ export { GRAPHQL_PLAYGROUND_GET, GRAPHQL_POST } from './graphql/index.js'
|
||||
export {
|
||||
DELETE as REST_DELETE,
|
||||
GET as REST_GET,
|
||||
OPTIONS as REST_OPTIONS,
|
||||
PATCH as REST_PATCH,
|
||||
POST as REST_POST,
|
||||
} from './rest/index.js'
|
||||
|
||||
@@ -14,25 +14,24 @@ type GetRequestLanguageArgs = {
|
||||
export const getRequestLanguage = ({
|
||||
config,
|
||||
cookies,
|
||||
defaultLanguage = 'en',
|
||||
headers,
|
||||
}: GetRequestLanguageArgs): AcceptedLanguages => {
|
||||
const supportedLanguageKeys = <AcceptedLanguages[]>Object.keys(config.i18n.supportedLanguages)
|
||||
const langCookie = cookies.get(`${config.cookiePrefix || 'payload'}-lng`)
|
||||
const languageFromCookie = typeof langCookie === 'string' ? langCookie : langCookie?.value
|
||||
const languageFromCookie: AcceptedLanguages = (
|
||||
typeof langCookie === 'string' ? langCookie : langCookie?.value
|
||||
) as AcceptedLanguages
|
||||
const languageFromHeader = headers.get('Accept-Language')
|
||||
? extractHeaderLanguage(headers.get('Accept-Language'))
|
||||
: undefined
|
||||
const fallbackLang = config?.i18n?.fallbackLanguage || defaultLanguage
|
||||
|
||||
const supportedLanguageKeys = Object.keys(config?.i18n?.supportedLanguages || {})
|
||||
|
||||
if (languageFromCookie && supportedLanguageKeys.includes(languageFromCookie)) {
|
||||
return languageFromCookie as AcceptedLanguages
|
||||
return languageFromCookie
|
||||
}
|
||||
|
||||
if (languageFromHeader && supportedLanguageKeys.includes(languageFromHeader)) {
|
||||
return languageFromHeader
|
||||
}
|
||||
|
||||
return supportedLanguageKeys.includes(fallbackLang) ? (fallbackLang as AcceptedLanguages) : 'en'
|
||||
return config.i18n.fallbackLanguage
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const LivePreview: React.FC<EditViewProps> = (props) => {
|
||||
|
||||
const [fields] = useAllFormFields()
|
||||
|
||||
// For client-side apps, send data through `window.postMessage`
|
||||
// The preview could either be an iframe embedded on the page
|
||||
// Or it could be a separate popup window
|
||||
// We need to transmit data to both accordingly
|
||||
@@ -83,6 +84,25 @@ export const LivePreview: React.FC<EditViewProps> = (props) => {
|
||||
mostRecentUpdate,
|
||||
])
|
||||
|
||||
// To support SSR, we transmit a `window.postMessage` event without a payload
|
||||
// This is because the event will ultimately trigger a server-side roundtrip
|
||||
// i.e., save, save draft, autosave, etc. will fire `router.refresh()`
|
||||
useEffect(() => {
|
||||
const message = {
|
||||
type: 'payload-document-event',
|
||||
}
|
||||
|
||||
// Post message to external popup window
|
||||
if (previewWindowType === 'popup' && popupRef.current) {
|
||||
popupRef.current.postMessage(message, url)
|
||||
}
|
||||
|
||||
// Post message to embedded iframe
|
||||
if (previewWindowType === 'iframe' && iframeRef.current) {
|
||||
iframeRef.current.contentWindow?.postMessage(message, url)
|
||||
}
|
||||
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url])
|
||||
|
||||
if (previewWindowType === 'iframe') {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
|
||||
"keywords": [
|
||||
"admin panel",
|
||||
|
||||
@@ -92,8 +92,15 @@ export type RichTextAdapterProvider<
|
||||
ExtraFieldProperties = {},
|
||||
> = ({
|
||||
config,
|
||||
isRoot,
|
||||
}: {
|
||||
config: SanitizedConfig
|
||||
/**
|
||||
* Whether or not this is the root richText editor, defined in the payload.config.ts.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
isRoot?: boolean
|
||||
}) =>
|
||||
| Promise<RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>>
|
||||
| RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>
|
||||
|
||||
@@ -28,6 +28,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
|
||||
maxComplexity: 1000,
|
||||
},
|
||||
hooks: {},
|
||||
i18n: {},
|
||||
localization: false,
|
||||
maxDepth: 10,
|
||||
routes: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AcceptedLanguages } from '@payloadcms/translations'
|
||||
|
||||
import { en } from '@payloadcms/translations/languages/en'
|
||||
import merge from 'deepmerge'
|
||||
|
||||
@@ -88,15 +90,29 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
|
||||
}
|
||||
}
|
||||
|
||||
config.i18n = {
|
||||
const i18nConfig: SanitizedConfig['i18n'] = {
|
||||
fallbackLanguage: 'en',
|
||||
supportedLanguages: {
|
||||
en,
|
||||
},
|
||||
translations: {},
|
||||
...(incomingConfig?.i18n ?? {}),
|
||||
}
|
||||
|
||||
if (incomingConfig?.i18n) {
|
||||
i18nConfig.supportedLanguages =
|
||||
incomingConfig.i18n?.supportedLanguages || i18nConfig.supportedLanguages
|
||||
|
||||
const supportedLangKeys = <AcceptedLanguages[]>Object.keys(i18nConfig.supportedLanguages)
|
||||
const fallbackLang = incomingConfig.i18n?.fallbackLanguage || i18nConfig.fallbackLanguage
|
||||
|
||||
i18nConfig.fallbackLanguage = supportedLangKeys.includes(fallbackLang)
|
||||
? fallbackLang
|
||||
: supportedLangKeys[0]
|
||||
i18nConfig.translations = incomingConfig.i18n?.translations || i18nConfig.translations
|
||||
}
|
||||
|
||||
config.i18n = i18nConfig
|
||||
|
||||
configWithDefaults.collections.push(getPreferencesCollection(config as unknown as Config))
|
||||
configWithDefaults.collections.push(migrationsCollection)
|
||||
|
||||
@@ -134,6 +150,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
|
||||
if (typeof incomingConfig.editor === 'function') {
|
||||
config.editor = await incomingConfig.editor({
|
||||
config: config as SanitizedConfig,
|
||||
isRoot: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { APIError } from './APIError.js'
|
||||
export class MissingEditorProp extends APIError {
|
||||
constructor(field: Field) {
|
||||
super(
|
||||
`RichText field${fieldAffectsData(field) ? ` "${field.name}"` : ''} is missing the editor prop`,
|
||||
`RichText field${fieldAffectsData(field) ? ` "${field.name}"` : ''} is missing the editor prop. For sub-richText fields, the editor props is required, as it would otherwise create infinite recursion.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ export type {
|
||||
FieldWithMany,
|
||||
FieldWithMaxDepth,
|
||||
FieldWithPath,
|
||||
FieldWithRichTextRequiredEditor,
|
||||
FieldWithSubFields,
|
||||
FilterOptions,
|
||||
FilterOptionsProps,
|
||||
@@ -91,7 +90,6 @@ export type {
|
||||
RelationshipField,
|
||||
RelationshipValue,
|
||||
RichTextField,
|
||||
RichTextFieldRequiredEditor,
|
||||
RowAdmin,
|
||||
RowField,
|
||||
SelectField,
|
||||
|
||||
@@ -161,7 +161,10 @@ export const sanitizeFields = async ({
|
||||
}
|
||||
|
||||
if (typeof field.editor === 'function') {
|
||||
field.editor = await field.editor({ config: _config })
|
||||
field.editor = await field.editor({
|
||||
config: _config,
|
||||
isRoot: requireFieldLevelRichTextEditor,
|
||||
})
|
||||
}
|
||||
|
||||
// Add editor adapter hooks to field hooks
|
||||
|
||||
@@ -318,6 +318,7 @@ export const array = baseField.keys({
|
||||
RowLabel: componentSchema,
|
||||
})
|
||||
.default({}),
|
||||
isSortable: joi.boolean(),
|
||||
})
|
||||
.default({}),
|
||||
dbName: joi.alternatives().try(joi.string(), joi.func()),
|
||||
@@ -415,6 +416,11 @@ export const relationship = baseField.keys({
|
||||
export const blocks = baseField.keys({
|
||||
name: joi.string().required(),
|
||||
type: joi.string().valid('blocks').required(),
|
||||
admin: baseAdminFields
|
||||
.keys({
|
||||
isSortable: joi.boolean(),
|
||||
})
|
||||
.default({}),
|
||||
blocks: joi
|
||||
.array()
|
||||
.items(
|
||||
@@ -477,6 +483,7 @@ export const richText = baseField.keys({
|
||||
validate: joi.func().required(),
|
||||
})
|
||||
.unknown(),
|
||||
maxDepth: joi.number(),
|
||||
})
|
||||
|
||||
export const date = baseField.keys({
|
||||
|
||||
@@ -446,6 +446,11 @@ export type UploadField = FieldBase & {
|
||||
}
|
||||
}
|
||||
filterOptions?: FilterOptions
|
||||
/**
|
||||
* Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached.
|
||||
*
|
||||
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
|
||||
*/
|
||||
maxDepth?: number
|
||||
relationTo: string
|
||||
type: 'upload'
|
||||
@@ -506,6 +511,11 @@ export type SelectField = FieldBase & {
|
||||
type SharedRelationshipProperties = FieldBase & {
|
||||
filterOptions?: FilterOptions
|
||||
hasMany?: boolean
|
||||
/**
|
||||
* Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached.
|
||||
*
|
||||
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
|
||||
*/
|
||||
maxDepth?: number
|
||||
type: 'relationship'
|
||||
} & (
|
||||
@@ -588,25 +598,25 @@ export type RichTextField<
|
||||
editor?:
|
||||
| RichTextAdapter<Value, AdapterProps, AdapterProps>
|
||||
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
|
||||
/**
|
||||
* Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached.
|
||||
*
|
||||
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
|
||||
*/
|
||||
maxDepth?: number
|
||||
type: 'richText'
|
||||
} & ExtraProperties
|
||||
|
||||
export type RichTextFieldRequiredEditor<
|
||||
Value extends object = any,
|
||||
AdapterProps = any,
|
||||
ExtraProperties = object,
|
||||
> = Omit<RichTextField<Value, AdapterProps, ExtraProperties>, 'editor'> & {
|
||||
editor:
|
||||
| RichTextAdapter<Value, AdapterProps, AdapterProps>
|
||||
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
|
||||
}
|
||||
|
||||
export type ArrayField = FieldBase & {
|
||||
admin?: Admin & {
|
||||
components?: {
|
||||
RowLabel?: RowLabel
|
||||
} & Admin['components']
|
||||
initCollapsed?: boolean
|
||||
/**
|
||||
* Disable drag and drop sorting
|
||||
*/
|
||||
isSortable?: boolean
|
||||
}
|
||||
/**
|
||||
* Customize the SQL table name
|
||||
@@ -678,6 +688,10 @@ export type Block = {
|
||||
export type BlockField = FieldBase & {
|
||||
admin?: Admin & {
|
||||
initCollapsed?: boolean
|
||||
/**
|
||||
* Disable drag and drop sorting
|
||||
*/
|
||||
isSortable?: boolean
|
||||
}
|
||||
blocks: Block[]
|
||||
defaultValue?: unknown
|
||||
@@ -714,10 +728,6 @@ export type Field =
|
||||
| UIField
|
||||
| UploadField
|
||||
|
||||
export type FieldWithRichTextRequiredEditor =
|
||||
| Exclude<Field, RichTextField>
|
||||
| RichTextFieldRequiredEditor
|
||||
|
||||
export type FieldAffectingData =
|
||||
| ArrayField
|
||||
| BlockField
|
||||
|
||||
@@ -150,10 +150,13 @@ export const promise = async ({
|
||||
const editor: RichTextAdapter = field?.editor
|
||||
// This is run here AND in the GraphQL Resolver
|
||||
if (editor?.populationPromises) {
|
||||
const populateDepth =
|
||||
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
|
||||
|
||||
editor.populationPromises({
|
||||
context,
|
||||
currentDepth,
|
||||
depth,
|
||||
depth: populateDepth,
|
||||
draft,
|
||||
field,
|
||||
fieldPromises,
|
||||
|
||||
@@ -50,7 +50,7 @@ import { JWTAuthentication } from './auth/strategies/jwt.js'
|
||||
import localOperations from './collections/operations/local/index.js'
|
||||
import { validateSchema } from './config/validate.js'
|
||||
import { consoleEmailAdapter } from './email/consoleEmailAdapter.js'
|
||||
import { fieldAffectsData } from './exports/types.js'
|
||||
import { fieldAffectsData } from './fields/config/types.js'
|
||||
import localGlobalOperations from './globals/operations/local/index.js'
|
||||
import flattenFields from './utilities/flattenTopLevelFields.js'
|
||||
import Logger from './utilities/logger.js'
|
||||
|
||||
@@ -6,10 +6,10 @@ import type { SanitizedConfig } from '../config/types.js'
|
||||
|
||||
export const getLocalI18n = async ({
|
||||
config,
|
||||
language = 'en',
|
||||
language,
|
||||
}: {
|
||||
config: SanitizedConfig
|
||||
language?: AcceptedLanguages
|
||||
language: AcceptedLanguages
|
||||
}) =>
|
||||
initI18n({
|
||||
config: config.i18n,
|
||||
|
||||
@@ -68,8 +68,6 @@ export const createLocalReq: CreateLocalReq = async (
|
||||
{ context, fallbackLocale, locale: localeArg, req = {} as PayloadRequestWithData, user },
|
||||
payload,
|
||||
) => {
|
||||
const i18n = req?.i18n || (await getLocalI18n({ config: payload.config }))
|
||||
|
||||
if (payload.config?.localization) {
|
||||
const locale = localeArg === '*' ? 'all' : localeArg
|
||||
const defaultLocale = payload.config.localization.defaultLocale
|
||||
@@ -84,6 +82,10 @@ export const createLocalReq: CreateLocalReq = async (
|
||||
}
|
||||
}
|
||||
|
||||
const i18n =
|
||||
req?.i18n ||
|
||||
(await getLocalI18n({ config: payload.config, language: payload.config.i18n.fallbackLanguage }))
|
||||
|
||||
// @ts-expect-error
|
||||
if (!req.headers) req.headers = new Headers()
|
||||
req.context = getRequestContext(req, context)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-cloud-storage",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The official cloud storage plugin for Payload CMS",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-cloud",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The official Payload Cloud plugin",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-form-builder",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "Form builder plugin for Payload CMS",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-nested-docs",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The official Nested Docs plugin for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-redirects",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "Redirects plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-search",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "Search plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-seo",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "SEO plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-stripe",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "Stripe plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -20,8 +20,7 @@ export const stripePlugin =
|
||||
// set config defaults here
|
||||
const pluginConfig: SanitizedStripePluginConfig = {
|
||||
...incomingStripeConfig,
|
||||
// TODO: in the next major version, default this to `false`
|
||||
rest: incomingStripeConfig?.rest ?? true,
|
||||
rest: incomingStripeConfig?.rest ?? false,
|
||||
sync: incomingStripeConfig?.sync || [],
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export type SyncConfig = {
|
||||
export type StripePluginConfig = {
|
||||
isTestKey?: boolean
|
||||
logs?: boolean
|
||||
// @deprecated this will default as `false` in the next major version release
|
||||
/** @default false */
|
||||
rest?: boolean
|
||||
stripeSecretKey: string
|
||||
stripeWebhooksEndpointSecret?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/richtext-lexical",
|
||||
"version": "3.0.0-beta.25",
|
||||
"version": "3.0.0-beta.27",
|
||||
"description": "The officially supported Lexical richtext adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export { RichTextCell } from '../cell/index.js'
|
||||
export { ToolbarButton } from '../field/features/toolbars/shared/ToolbarButton/index.js'
|
||||
export { createClientComponent } from '../field/features/createClientComponent.js'
|
||||
|
||||
export { toolbarFormatGroupWithItems } from '../field/features/format/shared/toolbarFormatGroup.js'
|
||||
export { toolbarAddDropdownGroupWithItems } from '../field/features/shared/toolbar/addDropdownGroup.js'
|
||||
export { toolbarFeatureButtonsGroupWithItems } from '../field/features/shared/toolbar/featureButtonsGroup.js'
|
||||
export { toolbarTextDropdownGroupWithItems } from '../field/features/shared/toolbar/textDropdownGroup.js'
|
||||
export { ToolbarButton } from '../field/features/toolbars/shared/ToolbarButton/index.js'
|
||||
export { ToolbarDropdown } from '../field/features/toolbars/shared/ToolbarDropdown/index.js'
|
||||
|
||||
export { RichTextField } from '../field/index.js'
|
||||
export {
|
||||
type EditorFocusContextType,
|
||||
|
||||
@@ -40,7 +40,7 @@ const toolbarGroups: ToolbarGroup[] = [
|
||||
]),
|
||||
]
|
||||
|
||||
const BlockQuoteFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
|
||||
const BlockquoteFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
|
||||
return {
|
||||
clientFeatureProps: props,
|
||||
feature: () => ({
|
||||
@@ -80,4 +80,4 @@ const BlockQuoteFeatureClient: FeatureProviderProviderClient<undefined> = (props
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockQuoteFeatureClientComponent = createClientComponent(BlockQuoteFeatureClient)
|
||||
export const BlockquoteFeatureClientComponent = createClientComponent(BlockquoteFeatureClient)
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { FeatureProviderProviderServer } from '../types.js'
|
||||
|
||||
import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js'
|
||||
import { createNode } from '../typeUtilities.js'
|
||||
import { BlockQuoteFeatureClientComponent } from './feature.client.js'
|
||||
import { BlockquoteFeatureClientComponent } from './feature.client.js'
|
||||
import { MarkdownTransformer } from './markdownTransformer.js'
|
||||
|
||||
export const BlockQuoteFeature: FeatureProviderProviderServer<undefined, undefined> = (props) => {
|
||||
export const BlockquoteFeature: FeatureProviderProviderServer<undefined, undefined> = (props) => {
|
||||
return {
|
||||
feature: () => {
|
||||
return {
|
||||
ClientComponent: BlockQuoteFeatureClientComponent,
|
||||
ClientComponent: BlockquoteFeatureClientComponent,
|
||||
clientFeatureProps: null,
|
||||
markdownTransformers: [MarkdownTransformer],
|
||||
nodes: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config } from 'payload/config'
|
||||
import type { Block, BlockField, Field, FieldWithRichTextRequiredEditor } from 'payload/types'
|
||||
import type { Block, BlockField, Field } from 'payload/types'
|
||||
|
||||
import { traverseFields } from '@payloadcms/next/utilities'
|
||||
import { baseBlockFields, sanitizeFields } from 'payload/config'
|
||||
@@ -14,12 +14,8 @@ import { BlockNode } from './nodes/BlocksNode.js'
|
||||
import { blockPopulationPromiseHOC } from './populationPromise.js'
|
||||
import { blockValidationHOC } from './validate.js'
|
||||
|
||||
export type LexicalBlock = Omit<Block, 'fields'> & {
|
||||
fields: FieldWithRichTextRequiredEditor[]
|
||||
}
|
||||
|
||||
export type BlocksFeatureProps = {
|
||||
blocks: LexicalBlock[]
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
export const BlocksFeature: FeatureProviderProviderServer<
|
||||
@@ -27,20 +23,20 @@ export const BlocksFeature: FeatureProviderProviderServer<
|
||||
BlocksFeatureClientProps
|
||||
> = (props) => {
|
||||
return {
|
||||
feature: async ({ config: _config }) => {
|
||||
feature: async ({ config: _config, isRoot }) => {
|
||||
if (props?.blocks?.length) {
|
||||
const validRelationships = _config.collections.map((c) => c.slug) || []
|
||||
|
||||
for (const block of props.blocks) {
|
||||
block.fields = block.fields.concat(baseBlockFields as FieldWithRichTextRequiredEditor[])
|
||||
block.fields = block.fields.concat(baseBlockFields)
|
||||
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
|
||||
|
||||
block.fields = (await sanitizeFields({
|
||||
block.fields = await sanitizeFields({
|
||||
config: _config as unknown as Config,
|
||||
fields: block.fields,
|
||||
requireFieldLevelRichTextEditor: true,
|
||||
requireFieldLevelRichTextEditor: isRoot,
|
||||
validRelationships,
|
||||
})) as FieldWithRichTextRequiredEditor[]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'lexical'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../types.js'
|
||||
import type { BlocksFeatureClientProps } from '../feature.client.js'
|
||||
import type { BlockFields } from '../nodes/BlocksNode.js'
|
||||
|
||||
import { BlocksDrawerComponent } from '../drawer/index.js'
|
||||
@@ -18,7 +20,7 @@ import { INSERT_BLOCK_COMMAND } from './commands.js'
|
||||
|
||||
export type InsertBlockPayload = Exclude<BlockFields, 'id'>
|
||||
|
||||
export function BlocksPlugin(): React.ReactNode {
|
||||
export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../../types.js'
|
||||
|
||||
import { IS_APPLE } from '../../../../lexical/utils/environment.js'
|
||||
import './index.scss'
|
||||
|
||||
@@ -442,7 +444,7 @@ ${steps.map(formatStep).join(`\n`)}
|
||||
|
||||
return [button, output]
|
||||
}
|
||||
export const TestRecorderPlugin: React.FC = () => {
|
||||
export const TestRecorderPlugin: PluginComponent<undefined> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [testRecorderButton, testRecorderOutput] = useTestRecorder(editor)
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { TreeView } from '@lexical/react/LexicalTreeView.js'
|
||||
import * as React from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../../types.js'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
export function TreeViewPlugin(): React.ReactNode {
|
||||
export const TreeViewPlugin: PluginComponent<undefined> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return (
|
||||
<TreeView
|
||||
|
||||
@@ -5,6 +5,8 @@ import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../types.js'
|
||||
|
||||
import {
|
||||
$createHorizontalRuleNode,
|
||||
INSERT_HORIZONTAL_RULE_COMMAND,
|
||||
@@ -14,7 +16,7 @@ import './index.scss'
|
||||
/**
|
||||
* Registers the INSERT_HORIZONTAL_RULE_COMMAND lexical command and defines the behavior for when it is called.
|
||||
*/
|
||||
export function HorizontalRulePlugin(): null {
|
||||
export const HorizontalRulePlugin: PluginComponent<undefined> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { User } from 'payload/auth'
|
||||
import type { SanitizedConfig } from 'payload/config'
|
||||
import type { Field, FieldWithRichTextRequiredEditor, RadioField, TextField } from 'payload/types'
|
||||
import type { Field, RadioField, TextField } from 'payload/types'
|
||||
|
||||
import { validateUrl } from '../../../lexical/utils/url.js'
|
||||
|
||||
@@ -8,7 +8,8 @@ export const getBaseFields = (
|
||||
config: SanitizedConfig,
|
||||
enabledCollections: false | string[],
|
||||
disabledCollections: false | string[],
|
||||
): FieldWithRichTextRequiredEditor[] => {
|
||||
maxDepth?: number,
|
||||
): Field[] => {
|
||||
let enabledRelations: string[]
|
||||
|
||||
/**
|
||||
@@ -97,6 +98,7 @@ export const getBaseFields = (
|
||||
}
|
||||
: null,
|
||||
label: ({ t }) => t('fields:chooseDocumentToLink'),
|
||||
maxDepth,
|
||||
relationTo: enabledRelations,
|
||||
required: true,
|
||||
})
|
||||
@@ -108,5 +110,5 @@ export const getBaseFields = (
|
||||
label: ({ t }) => t('fields:openInNewTab'),
|
||||
})
|
||||
|
||||
return baseFields as FieldWithRichTextRequiredEditor[]
|
||||
return baseFields
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ import type { LinkFields } from '../nodes/types.js'
|
||||
export interface Props {
|
||||
drawerSlug: string
|
||||
handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
|
||||
stateData?: LinkFields & { text: string }
|
||||
stateData: {} | (LinkFields & { text: string })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { LexicalNode } from 'lexical'
|
||||
|
||||
import { $findMatchingParent } from '@lexical/utils'
|
||||
import { $getSelection, $isRangeSelection } from 'lexical'
|
||||
|
||||
@@ -34,22 +36,35 @@ const toolbarGroups: ToolbarGroup[] = [
|
||||
}
|
||||
return false
|
||||
},
|
||||
isEnabled: ({ selection }) => {
|
||||
return !!($isRangeSelection(selection) && $getSelection()?.getTextContent()?.length)
|
||||
},
|
||||
key: 'link',
|
||||
label: `Link`,
|
||||
onSelect: ({ editor, isActive }) => {
|
||||
if (!isActive) {
|
||||
let selectedText = null
|
||||
let selectedText: string = null
|
||||
let selectedNodes: LexicalNode[] = []
|
||||
editor.getEditorState().read(() => {
|
||||
selectedText = $getSelection().getTextContent()
|
||||
selectedText = $getSelection()?.getTextContent()
|
||||
// We need to selected nodes here before the drawer opens, as clicking around in the drawer may change the original selection
|
||||
selectedNodes = $getSelection()?.getNodes() ?? []
|
||||
})
|
||||
|
||||
if (!selectedText?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const linkFields: LinkFields = {
|
||||
doc: null,
|
||||
linkType: 'custom',
|
||||
newTab: false,
|
||||
url: 'https://',
|
||||
}
|
||||
|
||||
editor.dispatchCommand(TOGGLE_LINK_WITH_MODAL_COMMAND, {
|
||||
fields: linkFields,
|
||||
selectedNodes,
|
||||
text: selectedText,
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config, SanitizedConfig } from 'payload/config'
|
||||
import type { Field, FieldWithRichTextRequiredEditor } from 'payload/types'
|
||||
import type { Field } from 'payload/types'
|
||||
|
||||
import { traverseFields } from '@payloadcms/next/utilities'
|
||||
import { sanitizeFields } from 'payload/config'
|
||||
@@ -44,12 +44,14 @@ export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & {
|
||||
* A function or array defining additional fields for the link feature. These will be
|
||||
* displayed in the link editor drawer.
|
||||
*/
|
||||
fields?:
|
||||
| ((args: {
|
||||
config: SanitizedConfig
|
||||
defaultFields: FieldWithRichTextRequiredEditor[]
|
||||
}) => FieldWithRichTextRequiredEditor[])
|
||||
| FieldWithRichTextRequiredEditor[]
|
||||
fields?: ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[]) | Field[]
|
||||
/**
|
||||
* Sets a maximum population depth for the internal doc default field of link, regardless of the remaining depth when the field is reached.
|
||||
* This behaves exactly like the maxDepth properties of relationship and upload fields.
|
||||
*
|
||||
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps, ClientProps> = (
|
||||
@@ -59,7 +61,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
|
||||
props = {}
|
||||
}
|
||||
return {
|
||||
feature: async ({ config: _config }) => {
|
||||
feature: async ({ config: _config, isRoot }) => {
|
||||
const validRelationships = _config.collections.map((c) => c.slug) || []
|
||||
|
||||
const _transformedFields = transformExtraFields(
|
||||
@@ -67,14 +69,15 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
|
||||
_config,
|
||||
props.enabledCollections,
|
||||
props.disabledCollections,
|
||||
props.maxDepth,
|
||||
)
|
||||
|
||||
const sanitizedFields = (await sanitizeFields({
|
||||
const sanitizedFields = await sanitizeFields({
|
||||
config: _config as unknown as Config,
|
||||
fields: _transformedFields,
|
||||
requireFieldLevelRichTextEditor: true,
|
||||
requireFieldLevelRichTextEditor: isRoot,
|
||||
validRelationships,
|
||||
})) as FieldWithRichTextRequiredEditor[]
|
||||
})
|
||||
props.fields = sanitizedFields
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { type TextNode } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../../types.js'
|
||||
import type { ClientProps } from '../../feature.client.js'
|
||||
import type { LinkFields } from '../../nodes/types.js'
|
||||
|
||||
import { invariant } from '../../../../lexical/utils/invariant.js'
|
||||
@@ -428,7 +430,7 @@ const MATCHERS = [
|
||||
}),
|
||||
]
|
||||
|
||||
export function AutoLinkPlugin(): JSX.Element | null {
|
||||
export const AutoLinkPlugin: PluginComponent<ClientProps> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useAutoLink(editor, MATCHERS)
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import LexicalClickableLinkPlugin from '@lexical/react/LexicalClickableLinkPlugin.js'
|
||||
import React from 'react'
|
||||
|
||||
export function ClickableLinkPlugin() {
|
||||
import type { PluginComponent } from '../../../types.js'
|
||||
import type { ClientProps } from '../../feature.client.js'
|
||||
|
||||
export const ClickableLinkPlugin: PluginComponent<ClientProps> = () => {
|
||||
const Component = LexicalClickableLinkPlugin.default || LexicalClickableLinkPlugin
|
||||
//@ts-expect-error ts being dumb
|
||||
return <Component />
|
||||
|
||||
@@ -50,7 +50,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
|
||||
|
||||
const { i18n, t } = useTranslation()
|
||||
|
||||
const [stateData, setStateData] = useState<LinkFields & { text: string }>(null)
|
||||
const [stateData, setStateData] = useState<{} | (LinkFields & { text: string })>({})
|
||||
|
||||
const { closeModal, toggleModal } = useModal()
|
||||
const editDepth = useEditDepth()
|
||||
@@ -64,89 +64,102 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
|
||||
depth: editDepth,
|
||||
})
|
||||
|
||||
const setNotLink = useCallback(() => {
|
||||
setIsLink(false)
|
||||
if (editorRef && editorRef.current) {
|
||||
editorRef.current.style.opacity = '0'
|
||||
editorRef.current.style.transform = 'translate(-10000px, -10000px)'
|
||||
}
|
||||
setIsAutoLink(false)
|
||||
setLinkUrl(null)
|
||||
setLinkLabel(null)
|
||||
setSelectedNodes([])
|
||||
setStateData({})
|
||||
}, [setIsLink, setLinkUrl, setLinkLabel, setSelectedNodes])
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
let selectedNodeDomRect: DOMRect | undefined = null
|
||||
|
||||
if (!$isRangeSelection(selection) || !selection) {
|
||||
setNotLink()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle the data displayed in the floating link editor & drawer when you click on a link node
|
||||
if ($isRangeSelection(selection)) {
|
||||
const focusNode = getSelectedNode(selection)
|
||||
selectedNodeDomRect = editor.getElementByKey(focusNode.getKey())?.getBoundingClientRect()
|
||||
const focusLinkParent: LinkNode = $findMatchingParent(focusNode, $isLinkNode)
|
||||
|
||||
// Prevent link modal from showing if selection spans further than the link: https://github.com/facebook/lexical/issues/4064
|
||||
const badNode = selection
|
||||
.getNodes()
|
||||
.filter((node) => !$isLineBreakNode(node))
|
||||
.find((node) => {
|
||||
const linkNode = $findMatchingParent(node, $isLinkNode)
|
||||
return (
|
||||
(focusLinkParent && !focusLinkParent.is(linkNode)) ||
|
||||
(linkNode && !linkNode.is(focusLinkParent))
|
||||
)
|
||||
})
|
||||
const focusNode = getSelectedNode(selection)
|
||||
selectedNodeDomRect = editor.getElementByKey(focusNode.getKey())?.getBoundingClientRect()
|
||||
const focusLinkParent: LinkNode = $findMatchingParent(focusNode, $isLinkNode)
|
||||
|
||||
if (focusLinkParent == null || badNode) {
|
||||
setIsLink(false)
|
||||
setIsAutoLink(false)
|
||||
setLinkUrl(null)
|
||||
setLinkLabel(null)
|
||||
setSelectedNodes([])
|
||||
return
|
||||
}
|
||||
// Prevent link modal from showing if selection spans further than the link: https://github.com/facebook/lexical/issues/4064
|
||||
const badNode = selection
|
||||
.getNodes()
|
||||
.filter((node) => !$isLineBreakNode(node))
|
||||
.find((node) => {
|
||||
const linkNode = $findMatchingParent(node, $isLinkNode)
|
||||
return (
|
||||
(focusLinkParent && !focusLinkParent.is(linkNode)) ||
|
||||
(linkNode && !linkNode.is(focusLinkParent))
|
||||
)
|
||||
})
|
||||
|
||||
// Initial state:
|
||||
const data: LinkFields & { text: string } = {
|
||||
doc: undefined,
|
||||
linkType: undefined,
|
||||
newTab: undefined,
|
||||
url: '',
|
||||
...focusLinkParent.getFields(),
|
||||
text: focusLinkParent.getTextContent(),
|
||||
}
|
||||
if (focusLinkParent == null || badNode) {
|
||||
setNotLink()
|
||||
return
|
||||
}
|
||||
|
||||
if (focusLinkParent.getFields()?.linkType === 'custom') {
|
||||
setLinkUrl(focusLinkParent.getFields()?.url ?? null)
|
||||
setLinkLabel(null)
|
||||
} else {
|
||||
// internal link
|
||||
// Initial state:
|
||||
const data: LinkFields & { text: string } = {
|
||||
doc: undefined,
|
||||
linkType: undefined,
|
||||
newTab: undefined,
|
||||
url: '',
|
||||
...focusLinkParent.getFields(),
|
||||
text: focusLinkParent.getTextContent(),
|
||||
}
|
||||
|
||||
if (focusLinkParent.getFields()?.linkType === 'custom') {
|
||||
setLinkUrl(focusLinkParent.getFields()?.url ?? null)
|
||||
setLinkLabel(null)
|
||||
} else {
|
||||
// internal link
|
||||
setLinkUrl(
|
||||
`/admin/collections/${focusLinkParent.getFields()?.doc?.relationTo}/${
|
||||
focusLinkParent.getFields()?.doc?.value
|
||||
}`,
|
||||
)
|
||||
|
||||
const relatedField = config.collections.find(
|
||||
(coll) => coll.slug === focusLinkParent.getFields()?.doc?.relationTo,
|
||||
)
|
||||
if (!relatedField) {
|
||||
// Usually happens if the user removed all default fields. In this case, we let them specify the label or do not display the label at all.
|
||||
// label could be a virtual field the user added. This is useful if they want to use the link feature for things other than links.
|
||||
setLinkLabel(
|
||||
focusLinkParent.getFields()?.label ? String(focusLinkParent.getFields()?.label) : null,
|
||||
)
|
||||
setLinkUrl(
|
||||
`/admin/collections/${focusLinkParent.getFields()?.doc?.relationTo}/${
|
||||
focusLinkParent.getFields()?.doc?.value
|
||||
}`,
|
||||
focusLinkParent.getFields()?.url ? String(focusLinkParent.getFields()?.url) : null,
|
||||
)
|
||||
|
||||
const relatedField = config.collections.find(
|
||||
(coll) => coll.slug === focusLinkParent.getFields()?.doc?.relationTo,
|
||||
)
|
||||
if (!relatedField) {
|
||||
// Usually happens if the user removed all default fields. In this case, we let them specify the label or do not display the label at all.
|
||||
// label could be a virtual field the user added. This is useful if they want to use the link feature for things other than links.
|
||||
setLinkLabel(
|
||||
focusLinkParent.getFields()?.label ? String(focusLinkParent.getFields()?.label) : null,
|
||||
)
|
||||
setLinkUrl(
|
||||
focusLinkParent.getFields()?.url ? String(focusLinkParent.getFields()?.url) : null,
|
||||
)
|
||||
} else {
|
||||
const label = t('fields:linkedTo', {
|
||||
label: getTranslation(relatedField.labels.singular, i18n),
|
||||
}).replace(/<[^>]*>?/g, '')
|
||||
setLinkLabel(label)
|
||||
}
|
||||
}
|
||||
|
||||
setStateData(data)
|
||||
setIsLink(true)
|
||||
setSelectedNodes(selection ? selection?.getNodes() : [])
|
||||
|
||||
if ($isAutoLinkNode(focusLinkParent)) {
|
||||
setIsAutoLink(true)
|
||||
} else {
|
||||
setIsAutoLink(false)
|
||||
const label = t('fields:linkedTo', {
|
||||
label: getTranslation(relatedField.labels.singular, i18n),
|
||||
}).replace(/<[^>]*>?/g, '')
|
||||
setLinkLabel(label)
|
||||
}
|
||||
}
|
||||
|
||||
setStateData(data)
|
||||
setIsLink(true)
|
||||
setSelectedNodes(selection ? selection?.getNodes() : [])
|
||||
|
||||
if ($isAutoLinkNode(focusLinkParent)) {
|
||||
setIsAutoLink(true)
|
||||
} else {
|
||||
setIsAutoLink(false)
|
||||
}
|
||||
|
||||
const editorElem = editorRef.current
|
||||
const nativeSelection = window.getSelection()
|
||||
const { activeElement } = document
|
||||
@@ -158,7 +171,6 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
@@ -182,7 +194,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
|
||||
}
|
||||
|
||||
return true
|
||||
}, [anchorElem, editor, config, t, i18n])
|
||||
}, [editor, setNotLink, config.collections, t, i18n, anchorElem])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
@@ -202,13 +214,6 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
|
||||
)
|
||||
}, [editor, updateLinkEditor, toggleModal, drawerSlug])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLink && editorRef) {
|
||||
editorRef.current.style.opacity = '0'
|
||||
editorRef.current.style.transform = 'translate(-10000px, -10000px)'
|
||||
}
|
||||
}, [isLink])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
@@ -253,8 +258,8 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
|
||||
KEY_ESCAPE_COMMAND,
|
||||
() => {
|
||||
if (isLink) {
|
||||
setIsLink(false)
|
||||
setIsAutoLink(false)
|
||||
setNotLink()
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -262,7 +267,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
)
|
||||
}, [editor, updateLinkEditor, setIsLink, isLink])
|
||||
}, [editor, updateLinkEditor, isLink, setNotLink])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
|
||||
@@ -7,11 +7,11 @@ html[data-theme='light'] {
|
||||
}
|
||||
|
||||
.link-editor {
|
||||
z-index: 10;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--color-base-0);
|
||||
padding: 0px 3.72px 0px 6.25px;
|
||||
padding: 0 3.72px 0 6.25px;
|
||||
vertical-align: middle;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import type { PluginComponentWithAnchor } from '../../../types.js'
|
||||
import type { ClientProps } from '../../feature.client.js'
|
||||
|
||||
import { LinkEditor } from './LinkEditor/index.js'
|
||||
import './index.scss'
|
||||
|
||||
export const FloatingLinkEditorPlugin: React.FC<{
|
||||
anchorElem: HTMLElement
|
||||
}> = (props) => {
|
||||
export const FloatingLinkEditorPlugin: PluginComponentWithAnchor<ClientProps> = (props) => {
|
||||
const { anchorElem = document.body } = props
|
||||
|
||||
return createPortal(<LinkEditor anchorElem={anchorElem} />, anchorElem)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SanitizedConfig } from 'payload/config'
|
||||
import type { FieldWithRichTextRequiredEditor } from 'payload/types'
|
||||
import type { Field } from 'payload/types'
|
||||
|
||||
import { getBaseFields } from '../../drawer/baseFields.js'
|
||||
|
||||
@@ -8,22 +8,21 @@ import { getBaseFields } from '../../drawer/baseFields.js'
|
||||
*/
|
||||
export function transformExtraFields(
|
||||
customFieldSchema:
|
||||
| ((args: {
|
||||
config: SanitizedConfig
|
||||
defaultFields: FieldWithRichTextRequiredEditor[]
|
||||
}) => FieldWithRichTextRequiredEditor[])
|
||||
| FieldWithRichTextRequiredEditor[],
|
||||
| ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[])
|
||||
| Field[],
|
||||
config: SanitizedConfig,
|
||||
enabledCollections?: false | string[],
|
||||
disabledCollections?: false | string[],
|
||||
): FieldWithRichTextRequiredEditor[] {
|
||||
const baseFields: FieldWithRichTextRequiredEditor[] = getBaseFields(
|
||||
maxDepth?: number,
|
||||
): Field[] {
|
||||
const baseFields: Field[] = getBaseFields(
|
||||
config,
|
||||
enabledCollections,
|
||||
disabledCollections,
|
||||
maxDepth,
|
||||
)
|
||||
|
||||
let fields: FieldWithRichTextRequiredEditor[]
|
||||
let fields: Field[]
|
||||
|
||||
if (typeof customFieldSchema === 'function') {
|
||||
fields = customFieldSchema({ config, defaultFields: baseFields })
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../../types.js'
|
||||
import type { ClientProps } from '../../feature.client.js'
|
||||
import type { LinkFields } from '../../nodes/types.js'
|
||||
import type { LinkPayload } from '../floatingLinkEditor/types.js'
|
||||
|
||||
import { validateUrl } from '../../../../lexical/utils/url.js'
|
||||
import { LinkNode, TOGGLE_LINK_COMMAND, toggleLink } from '../../nodes/LinkNode.js'
|
||||
|
||||
export function LinkPlugin(): null {
|
||||
export const LinkPlugin: PluginComponent<ClientProps> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FieldWithRichTextRequiredEditor } from 'payload/types'
|
||||
import type { Field } from 'payload/types'
|
||||
|
||||
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
|
||||
|
||||
@@ -28,7 +28,7 @@ export const linkValidation = (
|
||||
const result = await buildStateFromSchema({
|
||||
id,
|
||||
data,
|
||||
fieldSchema: props.fields as FieldWithRichTextRequiredEditor[], // Sanitized in feature.server.ts
|
||||
fieldSchema: props.fields as Field[], // Sanitized in feature.server.ts
|
||||
operation: operation === 'create' || operation === 'update' ? operation : 'update',
|
||||
preferences,
|
||||
req,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin.js'
|
||||
import React from 'react'
|
||||
|
||||
export function LexicalCheckListPlugin() {
|
||||
import type { PluginComponent } from '../../../types.js'
|
||||
|
||||
export const LexicalCheckListPlugin: PluginComponent<undefined> = () => {
|
||||
return <CheckListPlugin />
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin.js'
|
||||
import React from 'react'
|
||||
|
||||
export function LexicalListPlugin() {
|
||||
import type { PluginComponent } from '../../types.js'
|
||||
|
||||
export const LexicalListPlugin: PluginComponent<undefined> = () => {
|
||||
return <ListPlugin />
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
import { createFeaturePropComponent } from '../../../../../createFeaturePropComponent.js'
|
||||
import { _SlateBlockquoteConverter } from './converter.js'
|
||||
|
||||
export const BlockQuoteConverterClient = createFeaturePropComponent(_SlateBlockquoteConverter)
|
||||
export const BlockquoteConverterClient = createFeaturePropComponent(_SlateBlockquoteConverter)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SlateNodeConverterProvider } from '../../types.js'
|
||||
|
||||
import { BlockQuoteConverterClient } from './client.js'
|
||||
import { BlockquoteConverterClient } from './client.js'
|
||||
import { _SlateBlockquoteConverter } from './converter.js'
|
||||
|
||||
export const SlateBlockquoteConverter: SlateNodeConverterProvider = {
|
||||
ClientComponent: BlockQuoteConverterClient,
|
||||
ClientComponent: BlockquoteConverterClient,
|
||||
converter: _SlateBlockquoteConverter,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { withMergedProps } from '@payloadcms/ui/elements/withMergedProps'
|
||||
import { $isNodeSelection } from 'lexical'
|
||||
|
||||
import type { FeatureProviderProviderClient } from '../types.js'
|
||||
@@ -23,10 +22,7 @@ const RelationshipFeatureClient: FeatureProviderProviderClient<RelationshipFeatu
|
||||
nodes: [RelationshipNode],
|
||||
plugins: [
|
||||
{
|
||||
Component: withMergedProps({
|
||||
Component: RelationshipPlugin,
|
||||
toMergeIntoProps: props,
|
||||
}),
|
||||
Component: RelationshipPlugin,
|
||||
position: 'normal',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { FeatureProviderProviderServer } from '../types.js'
|
||||
import { createNode } from '../typeUtilities.js'
|
||||
import { RelationshipFeatureClientComponent } from './feature.client.js'
|
||||
import { RelationshipNode } from './nodes/RelationshipNode.js'
|
||||
import { relationshipPopulationPromise } from './populationPromise.js'
|
||||
import { relationshipPopulationPromiseHOC } from './populationPromise.js'
|
||||
|
||||
export type RelationshipFeatureProps =
|
||||
export type ExclusiveRelationshipFeatureProps =
|
||||
| {
|
||||
/**
|
||||
* The collections that should be disabled. Overrides the `enableRichTextRelationship` property in the collection config.
|
||||
@@ -27,6 +27,16 @@ export type RelationshipFeatureProps =
|
||||
enabledCollections?: string[]
|
||||
}
|
||||
|
||||
export type RelationshipFeatureProps = ExclusiveRelationshipFeatureProps & {
|
||||
/**
|
||||
* Sets a maximum population depth for this relationship, regardless of the remaining depth when the respective field is reached.
|
||||
* This behaves exactly like the maxDepth properties of relationship and upload fields.
|
||||
*
|
||||
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
export const RelationshipFeature: FeatureProviderProviderServer<
|
||||
RelationshipFeatureProps,
|
||||
RelationshipFeatureProps
|
||||
@@ -39,8 +49,7 @@ export const RelationshipFeature: FeatureProviderProviderServer<
|
||||
nodes: [
|
||||
createNode({
|
||||
node: RelationshipNode,
|
||||
populationPromises: [relationshipPopulationPromise],
|
||||
// TODO: Add validation similar to upload
|
||||
populationPromises: [relationshipPopulationPromiseHOC(props)],
|
||||
}),
|
||||
],
|
||||
serverFeatureProps: props,
|
||||
|
||||
@@ -121,8 +121,10 @@ const Component: React.FC<Props> = (props) => {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected)
|
||||
} else {
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
if (!isSelected) {
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useEffect } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../types.js'
|
||||
import type { RelationshipFeatureProps } from '../feature.server.js'
|
||||
import type { RelationshipData } from '../nodes/RelationshipNode.js'
|
||||
|
||||
@@ -25,17 +26,17 @@ export const INSERT_RELATIONSHIP_COMMAND: LexicalCommand<RelationshipData> = cre
|
||||
'INSERT_RELATIONSHIP_COMMAND',
|
||||
)
|
||||
|
||||
export function RelationshipPlugin(props?: RelationshipFeatureProps): React.ReactNode {
|
||||
export const RelationshipPlugin: PluginComponent<RelationshipFeatureProps> = ({ clientProps }) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { collections } = useConfig()
|
||||
|
||||
let enabledRelations: string[] = null
|
||||
|
||||
if (props?.enabledCollections) {
|
||||
enabledRelations = props?.enabledCollections
|
||||
} else if (props?.disabledCollections) {
|
||||
if (clientProps?.enabledCollections) {
|
||||
enabledRelations = clientProps?.enabledCollections
|
||||
} else if (clientProps?.disabledCollections) {
|
||||
enabledRelations = collections
|
||||
.filter(({ slug }) => !props?.disabledCollections?.includes(slug))
|
||||
.filter(({ slug }) => !clientProps?.disabledCollections?.includes(slug))
|
||||
.map(({ slug }) => slug)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,51 @@
|
||||
import type { PopulationPromise } from '../types.js'
|
||||
import type { RelationshipFeatureProps } from './feature.server.js'
|
||||
import type { SerializedRelationshipNode } from './nodes/RelationshipNode.js'
|
||||
|
||||
import { populate } from '../../../populate/populate.js'
|
||||
|
||||
export const relationshipPopulationPromise: PopulationPromise<SerializedRelationshipNode> = ({
|
||||
currentDepth,
|
||||
depth,
|
||||
draft,
|
||||
field,
|
||||
node,
|
||||
overrideAccess,
|
||||
populationPromises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
}) => {
|
||||
if (node?.value) {
|
||||
// @ts-expect-error
|
||||
const id = node?.value?.id || node?.value // for backwards-compatibility
|
||||
export const relationshipPopulationPromiseHOC = (
|
||||
props: RelationshipFeatureProps,
|
||||
): PopulationPromise<SerializedRelationshipNode> => {
|
||||
const relationshipPopulationPromise: PopulationPromise<SerializedRelationshipNode> = ({
|
||||
currentDepth,
|
||||
depth,
|
||||
draft,
|
||||
field,
|
||||
node,
|
||||
overrideAccess,
|
||||
populationPromises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
}) => {
|
||||
if (node?.value) {
|
||||
// @ts-expect-error
|
||||
const id = node?.value?.id || node?.value // for backwards-compatibility
|
||||
|
||||
const collection = req.payload.collections[node?.relationTo]
|
||||
const collection = req.payload.collections[node?.relationTo]
|
||||
|
||||
if (collection) {
|
||||
populationPromises.push(
|
||||
populate({
|
||||
id,
|
||||
collection,
|
||||
currentDepth,
|
||||
data: node,
|
||||
depth,
|
||||
draft,
|
||||
field,
|
||||
key: 'value',
|
||||
overrideAccess,
|
||||
req,
|
||||
showHiddenFields,
|
||||
}),
|
||||
)
|
||||
if (collection) {
|
||||
const populateDepth =
|
||||
props?.maxDepth !== undefined && props?.maxDepth < depth ? props?.maxDepth : depth
|
||||
|
||||
populationPromises.push(
|
||||
populate({
|
||||
id,
|
||||
collection,
|
||||
currentDepth,
|
||||
data: node,
|
||||
depth: populateDepth,
|
||||
draft,
|
||||
field,
|
||||
key: 'value',
|
||||
overrideAccess,
|
||||
req,
|
||||
showHiddenFields,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relationshipPopulationPromise
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ html[data-theme='dark'] {
|
||||
.fixed-toolbar {
|
||||
@include blur-bg(var(--theme-elevation-0));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
padding: 0 3.72px 0 6.25px;
|
||||
vertical-align: middle;
|
||||
height: 37.5px;
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: var(--doc-controls-height);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user