Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions docs/live-preview/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const config = buildConfig({

## Options

Setting up Live Preview is easy. This can be done either globally through the [Root Admin Config](../admin/overview), or on individual [Collection Admin Configs](../configuration/collections#admin-options) and [Global Admin Configs](../configuration/globals#admin-options). Once configured, a new "Live Preview" tab will appear at the top of enabled Documents. Navigating to this tab opens the preview window and loads your front-end application.
Setting up Live Preview is easy. This can be done either globally through the [Root Admin Config](../admin/overview), or on individual [Collection Admin Configs](../configuration/collections#admin-options) and [Global Admin Configs](../configuration/globals#admin-options). Once configured, a new "Live Preview" button will appear at the top of enabled Documents. Toggling this button opens the preview window and loads your front-end application.

The following options are available:

Expand Down Expand Up @@ -75,6 +75,8 @@ const config = buildConfig({

You can also pass a function in order to dynamically format URLs. This is useful for multi-tenant applications, localization, or any other scenario where the URL needs to be generated based on the Document being edited.

This is also useful for conditionally rendering Live Preview, similar to access control. See [Conditional Rendering](./conditional-rendering) for more details.

To set dynamic URLs, set the `admin.livePreview.url` property in your [Payload Config](../configuration/overview) to a function:

```ts
Expand Down Expand Up @@ -114,7 +116,17 @@ You can return either an absolute URL or relative URL from this function. If you
If your application requires a fully qualified URL, or you are attempting to preview with a frontend on a different domain, you can use the `req` property to build this URL:

```ts
url: ({ data, req }) => `${req.protocol}//${req.host}/${data.slug}` // highlight-line
url: ({ data, req }) => `${req.protocol}//${req.host}/${data.slug}`
```

#### Conditional Rendering

You can conditionally render Live Preview by returning `undefined` or `null` from the `url` function. This is similar to access control, where you may want to restrict who can use Live Preview based on certain criteria, such as the current user or document data.

For example, you could check the user's role and only enable Live Preview if they have the appropriate permissions:

```ts
url: ({ req }) => (req.user?.role === 'admin' ? '/hello-world' : null)
```

### Breakpoints
Expand Down
4 changes: 3 additions & 1 deletion packages/payload/src/collections/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,9 @@ export type CollectionAdminOptions = {
*/
listSearchableFields?: string[]
/**
* Live preview options
* Live Preview options.
*
* @see https://payloadcms.com/docs/live-preview/overview
*/
livePreview?: LivePreviewConfig
meta?: MetaConfig
Expand Down
2 changes: 1 addition & 1 deletion packages/payload/src/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations'
import type { DeepPartial } from 'ts-essentials'

import type { ImportMap } from '../bin/generateImportMap/index.js'
import type { ClientBlock, ClientField, Field } from '../fields/config/types.js'
import type { ClientBlock } from '../fields/config/types.js'
import type { BlockSlug, TypedUser } from '../index.js'
import type {
RootLivePreviewConfig,
Expand Down
15 changes: 12 additions & 3 deletions packages/payload/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ type Prettify<T> = {

export type Plugin = (config: Config) => Config | Promise<Config>

export type LivePreviewURLType = null | string | undefined

export type LivePreviewConfig = {
/**
Device breakpoints to use for the `iframe` of the Live Preview window.
Expand All @@ -154,7 +156,9 @@ export type LivePreviewConfig = {
* The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
* Payload will send a `window.postMessage()` to this URL with the document data in real-time.
* The frontend application is responsible for receiving the message and updating the UI accordingly.
* Use the `useLivePreview` hook to get started in React applications.
* @see https://payloadcms.com/docs/live-preview/frontend
*
* To conditionally render Live Preview, use a function that returns `undefined` or `null`.
*
* Note: this function may run often if autosave is enabled with a small interval.
* For performance, avoid long-running tasks or expensive operations within this function,
Expand All @@ -172,8 +176,8 @@ export type LivePreviewConfig = {
*/
payload: Payload
req: PayloadRequest
}) => Promise<string> | string)
| string
}) => LivePreviewURLType | Promise<LivePreviewURLType>)
| LivePreviewURLType
}

export type RootLivePreviewConfig = {
Expand Down Expand Up @@ -884,6 +888,11 @@ export type Config = {
*/
importMapFile?: string
}
/**
* Live Preview options.
*
* @see https://payloadcms.com/docs/live-preview/overview
*/
livePreview?: RootLivePreviewConfig
/** Base meta data to use for the Admin Panel. Included properties are titleSuffix, ogImage, and favicon. */
meta?: MetaConfig
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/elements/LivePreview/Toggler/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import './index.scss'
const baseClass = 'live-preview-toggler'

export const LivePreviewToggler: React.FC = () => {
const { isLivePreviewing, setIsLivePreviewing } = useLivePreviewContext()
const { isLivePreviewing, setIsLivePreviewing, url: livePreviewURL } = useLivePreviewContext()
const { t } = useTranslation()

if (!livePreviewURL) {
return null
}

return (
<button
aria-label={isLivePreviewing ? t('general:exitLivePreview') : t('general:livePreview')}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/providers/LivePreview/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client'
import type { LivePreviewConfig } from 'payload'
import type { LivePreviewConfig, LivePreviewURLType } from 'payload'
import type { Dispatch } from 'react'
import type React from 'react'

Expand Down Expand Up @@ -59,7 +59,7 @@ export interface LivePreviewContextType {
* It is important to know which one it is, so that we can opt in/out of certain behaviors, e.g. calling the server to get the URL.
*/
typeofLivePreviewURL?: 'function' | 'string'
url: string | undefined
url: LivePreviewURLType
zoom: number
}

Expand Down
12 changes: 9 additions & 3 deletions packages/ui/src/providers/LivePreview/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client'
import type { CollectionPreferences, LivePreviewConfig } from 'payload'
import type { CollectionPreferences, LivePreviewConfig, LivePreviewURLType } from 'payload'

import { DndContext } from '@dnd-kit/core'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
Expand Down Expand Up @@ -92,7 +92,11 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
*/
const setLivePreviewURL = useCallback<LivePreviewContextType['setURL']>(
(_incomingURL) => {
const incomingURL = formatAbsoluteURL(_incomingURL)
let incomingURL: LivePreviewURLType

if (typeof _incomingURL === 'string') {
incomingURL = formatAbsoluteURL(_incomingURL)
}

if (incomingURL !== url) {
setAppIsReady(false)
Expand All @@ -106,7 +110,9 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
* `url` needs to be relative to the window, which cannot be done on initial render.
*/
useEffect(() => {
setURL(formatAbsoluteURL(urlFromProps))
if (typeof urlFromProps === 'string') {
setURL(formatAbsoluteURL(urlFromProps))
}
}, [urlFromProps])

// The toolbar needs to freely drag and drop around the page
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/utilities/handleLivePreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
CollectionConfig,
GlobalConfig,
LivePreviewConfig,
LivePreviewURLType,
Operation,
PayloadRequest,
SanitizedConfig,
Expand Down Expand Up @@ -80,7 +81,7 @@ export const handleLivePreview = async ({
}): Promise<{
isLivePreviewEnabled?: boolean
livePreviewConfig?: LivePreviewConfig
livePreviewURL?: string
livePreviewURL?: LivePreviewURLType
}> => {
const collectionConfig = collectionSlug
? req.payload.collections[collectionSlug]?.config
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/views/Edit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export function DefaultEditView({
previewWindowType,
setURL: setLivePreviewURL,
typeofLivePreviewURL,
url: livePreviewURL,
} = useLivePreviewContext()

const abortOnChangeRef = useRef<AbortController>(null)
Expand Down Expand Up @@ -353,7 +354,7 @@ export function DefaultEditView({
setDocumentIsLocked(false)
}

if (livePreviewURL) {
if (isLivePreviewEnabled && typeofLivePreviewURL === 'function') {
setLivePreviewURL(livePreviewURL)
}

Expand Down Expand Up @@ -692,7 +693,7 @@ export function DefaultEditView({
/>
{AfterDocument}
</div>
{isLivePreviewEnabled && !isInDrawer && (
{isLivePreviewEnabled && !isInDrawer && livePreviewURL && (
<LivePreviewWindow collectionSlug={collectionSlug} globalSlug={globalSlug} />
)}
</div>
Expand Down
19 changes: 19 additions & 0 deletions test/live-preview/app/live-preview/(pages)/static/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Gutter } from '@payloadcms/ui'

import React, { Fragment } from 'react'

type Args = {
params: Promise<{
slug?: string
}>
}

export default async function TestPage(args: Args) {
return (
<Fragment>
<Gutter>
<p>This is a static page for testing.</p>
</Gutter>
</Fragment>
)
}
20 changes: 20 additions & 0 deletions test/live-preview/collections/NoURL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { CollectionConfig } from 'payload'

export const NoURLCollection: CollectionConfig = {
slug: 'no-url',
admin: {
livePreview: {
url: ({ data }) => (data?.enabled ? '/live-preview/static' : null),
},
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'enabled',
type: 'checkbox',
},
],
}
2 changes: 2 additions & 0 deletions test/live-preview/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { MediaBlock } from './blocks/MediaBlock/index.js'
import { Categories } from './collections/Categories.js'
import { CollectionLevelConfig } from './collections/CollectionLevelConfig.js'
import { Media } from './collections/Media.js'
import { NoURLCollection } from './collections/NoURL.js'
import { Pages } from './collections/Pages.js'
import { Posts } from './collections/Posts.js'
import { SSR } from './collections/SSR.js'
Expand Down Expand Up @@ -58,6 +59,7 @@ export default buildConfigWithDefaults({
Media,
CollectionLevelConfig,
StaticURLCollection,
NoURLCollection,
],
globals: [Header, Footer],
onInit: seed,
Expand Down
34 changes: 32 additions & 2 deletions test/live-preview/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'

import { devUser } from '../credentials.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
// throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import {
selectLivePreviewBreakpoint,
Expand Down Expand Up @@ -55,6 +60,7 @@ describe('Live Preview', () => {
let ssrAutosavePagesURLUtil: AdminUrlUtil
let payload: PayloadTestSDK<Config>
let user: any
let context: any

beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
Expand All @@ -65,7 +71,7 @@ describe('Live Preview', () => {
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
ssrAutosavePagesURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)

const context = await browser.newContext()
context = await browser.newContext()
page = await context.newPage()

initPageConsoleErrorCatch(page)
Expand All @@ -83,6 +89,12 @@ describe('Live Preview', () => {
})

beforeEach(async () => {
// await throttleTest({
// page,
// context,
// delay: 'Fast 4G',
// })

await reInitializeDB({
serverURL,
snapshotKey: 'livePreviewTest',
Expand Down Expand Up @@ -164,6 +176,24 @@ describe('Live Preview', () => {
await expect.poll(async () => iframe.getAttribute('src')).toMatch(/\/live-preview/)
})

test('collection — does not render iframe when live preview url is falsy', async () => {
const noURL = new AdminUrlUtil(serverURL, 'no-url')
await page.goto(noURL.create)
await page.locator('#field-title').fill('No URL')
await saveDocAndAssert(page)
const toggler = page.locator('button#live-preview-toggler')
await expect(toggler).toBeHidden()
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()

const enabledCheckbox = page.locator('#field-enabled')
await enabledCheckbox.check()
await saveDocAndAssert(page)

await expect(toggler).toBeVisible()
await toggleLivePreview(page)
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
})

test('collection — retains static URL across edits', async () => {
const util = new AdminUrlUtil(serverURL, 'static-url')
await page.goto(util.create)
Expand Down
19 changes: 19 additions & 0 deletions test/live-preview/prod/app/live-preview/(pages)/static/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Gutter } from '@payloadcms/ui'

import React, { Fragment } from 'react'

type Args = {
params: Promise<{
slug?: string
}>
}

export default async function TestPage(args: Args) {
return (
<Fragment>
<Gutter>
<p>This is a static page for testing.</p>
</Gutter>
</Fragment>
)
}