fix(ui): add support back for custom live preview components (#14037)

Fixes https://github.com/payloadcms/payload/issues/13308

Adds support for a custom live preview component back, we previously
supported this and it was allowed via the config types but it wasn't
being rendered.

Now we export the `useLivePreviewContext` hook and the original
`LivePreviewWindow` component too so that end users can wrap the live
preview functionality with anything custom that they may need
This commit is contained in:
Paul
2025-10-02 12:09:15 -07:00
committed by GitHub
parent 7088d25787
commit d826159fc0
14 changed files with 522 additions and 3 deletions

View File

@@ -80,6 +80,18 @@ export const renderDocumentSlots: (args: {
})
}
const LivePreview =
collectionConfig?.admin?.components?.views?.edit?.livePreview ||
globalConfig?.admin?.components?.views?.edit?.livePreview
if (LivePreview?.Component) {
components.LivePreview = RenderServerComponent({
Component: LivePreview.Component,
importMap: req.payload.importMap,
serverProps,
})
}
const descriptionFromConfig =
collectionConfig?.admin?.description || globalConfig?.admin?.description

View File

@@ -561,6 +561,7 @@ export type DocumentSlots = {
BeforeDocumentControls?: React.ReactNode
Description?: React.ReactNode
EditMenuItems?: React.ReactNode
LivePreview?: React.ReactNode
PreviewButton?: React.ReactNode
PublishButton?: React.ReactNode
SaveButton?: React.ReactNode

View File

@@ -415,3 +415,6 @@ export type {
RenderFieldServerFnArgs,
RenderFieldServerFnReturnType,
} from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js'
export { useLivePreviewContext } from '../../providers/LivePreview/context.js'
export { LivePreviewWindow } from '../../elements/LivePreview/Window/index.js'

View File

@@ -56,6 +56,7 @@ export function DefaultEditView({
BeforeDocumentControls,
Description,
EditMenuItems,
LivePreview: CustomLivePreview,
PreviewButton,
PublishButton,
SaveButton,
@@ -694,7 +695,11 @@ export function DefaultEditView({
{AfterDocument}
</div>
{isLivePreviewEnabled && !isInDrawer && livePreviewURL && (
<LivePreviewWindow collectionSlug={collectionSlug} globalSlug={globalSlug} />
<>
{CustomLivePreview || (
<LivePreviewWindow collectionSlug={collectionSlug} globalSlug={globalSlug} />
)}
</>
)}
</div>
</Form>

View File

@@ -0,0 +1,12 @@
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import { PAYLOAD_SERVER_URL } from '../../../_api/serverURL.js'
export const RefreshRouteOnSave: React.FC = () => {
const router = useRouter()
return <PayloadLivePreview refresh={() => router.refresh()} serverURL={PAYLOAD_SERVER_URL} />
}

View File

@@ -0,0 +1,55 @@
import { Gutter } from '@payloadcms/ui'
import { notFound } from 'next/navigation.js'
import React, { Fragment } from 'react'
import type { Page } from '../../../../../payload-types.js'
import { renderedPageTitleID, customLivePreviewSlug } from '../../../../../shared.js'
import { getDoc } from '../../../_api/getDoc.js'
import { getDocs } from '../../../_api/getDocs.js'
import { Blocks } from '../../../_components/Blocks/index.js'
import { Hero } from '../../../_components/Hero/index.js'
import { RefreshRouteOnSave } from './RefreshRouteOnSave.js'
type Args = {
params: Promise<{
slug?: string
}>
}
export default async function SSRAutosavePage({ params: paramsPromise }: Args) {
const { slug = '' } = await paramsPromise
const data = await getDoc<Page>({
slug,
collection: customLivePreviewSlug,
draft: true,
})
if (!data) {
notFound()
}
return (
<Fragment>
<RefreshRouteOnSave />
<Hero {...data?.hero} />
<Blocks blocks={data?.layout} />
<Gutter>
<div id={renderedPageTitleID}>{`For Testing: ${data.title}`}</div>
</Gutter>
</Fragment>
)
}
export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
const ssrPages = await getDocs<Page>(customLivePreviewSlug)
return ssrPages?.map((page) => {
return { slug: page.slug }
})
} catch (_err) {
return []
}
}

View File

@@ -0,0 +1,97 @@
import type { CollectionConfig } from 'payload'
import { Archive } from '../blocks/ArchiveBlock/index.js'
import { CallToAction } from '../blocks/CallToAction/index.js'
import { Content } from '../blocks/Content/index.js'
import { MediaBlock } from '../blocks/MediaBlock/index.js'
import { hero } from '../fields/hero.js'
import { customLivePreviewSlug, mediaSlug, tenantsSlug } from '../shared.js'
export const CustomLivePreview: CollectionConfig = {
slug: customLivePreviewSlug,
labels: {
singular: 'Custom Live Preview Page',
plural: 'Custom Live Preview Pages',
},
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
admin: {
useAsTitle: 'title',
defaultColumns: ['id', 'title', 'slug', 'createdAt'],
preview: (doc) => `/live-preview/ssr/${doc?.slug}`,
components: {
views: {
edit: {
livePreview: {
Component: '/components/CustomLivePreview.js#CustomLivePreview',
},
},
},
},
},
fields: [
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'tenant',
type: 'relationship',
relationTo: tenantsSlug,
admin: {
position: 'sidebar',
},
},
{
name: 'title',
type: 'text',
required: true,
},
{
type: 'tabs',
tabs: [
{
label: 'Hero',
fields: [hero],
},
{
label: 'Content',
fields: [
{
name: 'layout',
type: 'blocks',
blocks: [CallToAction, Content, MediaBlock, Archive],
},
],
},
],
},
{
name: 'meta',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'image',
type: 'upload',
relationTo: mediaSlug,
},
],
},
],
}

View File

@@ -0,0 +1,27 @@
'use client'
import { LivePreviewWindow, useDocumentInfo, useLivePreviewContext } from '@payloadcms/ui'
import './styles.css'
export function CustomLivePreview() {
const { collectionSlug, globalSlug } = useDocumentInfo()
const { isLivePreviewing, setURL, url } = useLivePreviewContext()
return (
<div
className={[
'custom-live-preview',
isLivePreviewing && `custom-live-preview--is-live-previewing`,
]
.filter(Boolean)
.join(' ')}
>
{isLivePreviewing && (
<>
<p>Custom live preview being rendered</p>
<LivePreviewWindow collectionSlug={collectionSlug} globalSlug={globalSlug} />
</>
)}
</div>
)
}

View File

@@ -0,0 +1,13 @@
.custom-live-preview {
width: 80%;
display: none;
overflow: hidden;
&.custom-live-preview--is-live-previewing {
display: block;
}
.live-preview-window {
width: 100%;
}
}

View File

@@ -6,6 +6,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { MediaBlock } from './blocks/MediaBlock/index.js'
import { Categories } from './collections/Categories.js'
import { CollectionLevelConfig } from './collections/CollectionLevelConfig.js'
import { CustomLivePreview } from './collections/CustomLivePreview.js'
import { Media } from './collections/Media.js'
import { NoURLCollection } from './collections/NoURL.js'
import { Pages } from './collections/Pages.js'
@@ -19,6 +20,7 @@ import { Footer } from './globals/Footer.js'
import { Header } from './globals/Header.js'
import { seed } from './seed/index.js'
import {
customLivePreviewSlug,
desktopBreakpoint,
mobileBreakpoint,
pagesSlug,
@@ -42,7 +44,13 @@ export default buildConfigWithDefaults({
// The Live Preview config cascades from the top down, properties are inherited from here
url: formatLivePreviewURL,
breakpoints: [mobileBreakpoint, desktopBreakpoint],
collections: [pagesSlug, postsSlug, ssrPagesSlug, ssrAutosavePagesSlug],
collections: [
pagesSlug,
postsSlug,
ssrPagesSlug,
ssrAutosavePagesSlug,
customLivePreviewSlug,
],
globals: ['header', 'footer'],
},
},
@@ -59,6 +67,7 @@ export default buildConfigWithDefaults({
Media,
CollectionLevelConfig,
StaticURLCollection,
CustomLivePreview,
NoURLCollection,
],
globals: [Header, Footer],

View File

@@ -36,6 +36,7 @@ import {
} from './helpers.js'
import {
collectionLevelConfigSlug,
customLivePreviewSlug,
desktopBreakpoint,
mobileBreakpoint,
pagesSlug,
@@ -58,6 +59,7 @@ describe('Live Preview', () => {
let postsURLUtil: AdminUrlUtil
let ssrPagesURLUtil: AdminUrlUtil
let ssrAutosavePagesURLUtil: AdminUrlUtil
let customLivePreviewURLUtil: AdminUrlUtil
let payload: PayloadTestSDK<Config>
let user: any
let context: any
@@ -69,6 +71,7 @@ describe('Live Preview', () => {
pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug)
postsURLUtil = new AdminUrlUtil(serverURL, postsSlug)
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
customLivePreviewURLUtil = new AdminUrlUtil(serverURL, customLivePreviewSlug)
ssrAutosavePagesURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
context = await browser.newContext()
@@ -720,4 +723,12 @@ describe('Live Preview', () => {
await ensureDeviceIsLeftAligned(page)
expect(true).toBeTruthy()
})
test('can open a custom live-preview', async () => {
await goToCollectionLivePreview(page, customLivePreviewURLUtil)
const customLivePreview = page.locator('.custom-live-preview')
await expect(customLivePreview).toContainText('Custom live preview being rendered')
})
})

View File

@@ -79,6 +79,7 @@ export interface Config {
media: Media;
'collection-level-config': CollectionLevelConfig;
'static-url': StaticUrl;
'custom-live-preview': CustomLivePreview;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -95,6 +96,7 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>;
'collection-level-config': CollectionLevelConfigSelect<false> | CollectionLevelConfigSelect<true>;
'static-url': StaticUrlSelect<false> | StaticUrlSelect<true>;
'custom-live-preview': CustomLivePreviewSelect<false> | CustomLivePreviewSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -897,6 +899,149 @@ export interface StaticUrl {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-live-preview".
*/
export interface CustomLivePreview {
id: string;
slug: string;
tenant?: (string | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
richText?:
| {
[k: string]: unknown;
}[]
| null;
media?: (string | null) | Media;
};
layout?:
| (
| {
invertBackground?: boolean | null;
richText?:
| {
[k: string]: unknown;
}[]
| null;
links?:
| {
link: {
type?: ('reference' | 'custom') | null;
newTab?: boolean | null;
reference?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
} | null);
url?: string | null;
label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('primary' | 'secondary') | null;
};
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'cta';
}
| {
invertBackground?: boolean | null;
columns?:
| {
size?: ('oneThird' | 'half' | 'twoThirds' | 'full') | null;
richText?:
| {
[k: string]: unknown;
}[]
| null;
enableLink?: boolean | null;
link?: {
type?: ('reference' | 'custom') | null;
newTab?: boolean | null;
reference?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
} | null);
url?: string | null;
label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('default' | 'primary' | 'secondary') | null;
};
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'content';
}
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: string | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
}
| {
introContent?:
| {
[k: string]: unknown;
}[]
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (string | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: string | Post;
}[]
| null;
/**
* This field is auto-populated after-read
*/
populatedDocs?:
| {
relationTo: 'posts';
value: string | Post;
}[]
| null;
/**
* This field is auto-populated after-read
*/
populatedDocsTotal?: number | null;
id?: string | null;
blockName?: string | null;
blockType: 'archive';
}
)[]
| null;
meta?: {
title?: string | null;
description?: string | null;
image?: (string | null) | Media;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -943,6 +1088,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'static-url';
value: string | StaticUrl;
} | null)
| ({
relationTo: 'custom-live-preview';
value: string | CustomLivePreview;
} | null);
globalSlug?: string | null;
user: {
@@ -1493,6 +1642,106 @@ export interface StaticUrlSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-live-preview_select".
*/
export interface CustomLivePreviewSelect<T extends boolean = true> {
slug?: T;
tenant?: T;
title?: T;
hero?:
| T
| {
type?: T;
richText?: T;
media?: T;
};
layout?:
| T
| {
cta?:
| T
| {
invertBackground?: T;
richText?: T;
links?:
| T
| {
link?:
| T
| {
type?: T;
newTab?: T;
reference?: T;
url?: T;
label?: T;
appearance?: T;
};
id?: T;
};
id?: T;
blockName?: T;
};
content?:
| T
| {
invertBackground?: T;
columns?:
| T
| {
size?: T;
richText?: T;
enableLink?: T;
link?:
| T
| {
type?: T;
newTab?: T;
reference?: T;
url?: T;
label?: T;
appearance?: T;
};
id?: T;
};
id?: T;
blockName?: T;
};
mediaBlock?:
| T
| {
invertBackground?: T;
position?: T;
media?: T;
id?: T;
blockName?: T;
};
archive?:
| T
| {
introContent?: T;
populateBy?: T;
relationTo?: T;
categories?: T;
limit?: T;
selectedDocs?: T;
populatedDocs?: T;
populatedDocsTotal?: T;
id?: T;
blockName?: T;
};
};
meta?:
| T
| {
title?: T;
description?: T;
image?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -5,7 +5,14 @@ import { fileURLToPath } from 'url'
import { devUser } from '../../credentials.js'
import removeFiles from '../../helpers/removeFiles.js'
import { pagesSlug, postsSlug, ssrAutosavePagesSlug, ssrPagesSlug, tenantsSlug } from '../shared.js'
import {
customLivePreviewSlug,
pagesSlug,
postsSlug,
ssrAutosavePagesSlug,
ssrPagesSlug,
tenantsSlug,
} from '../shared.js'
import { footer } from './footer.js'
import { header } from './header.js'
import { home } from './home.js'
@@ -132,6 +139,23 @@ export const seed: Config['onInit'] = async (payload) => {
),
})
await payload.create({
collection: customLivePreviewSlug,
data: {
...JSON.parse(
JSON.stringify(home)
.replace(/"\{\{MEDIA_ID\}\}"/g, mediaID)
.replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)
.replace(/"\{\{POST_1_ID\}\}"/g, post1DocID)
.replace(/"\{\{POST_2_ID\}\}"/g, post2DocID)
.replace(/"\{\{POST_3_ID\}\}"/g, post3DocID)
.replace(/"\{\{TENANT_1_ID\}\}"/g, tenantID),
),
title: 'Custom Live Preview',
slug: 'custom-live-preview',
},
})
await payload.create({
collection: ssrPagesSlug,
data: {

View File

@@ -1,6 +1,7 @@
export const pagesSlug = 'pages'
export const tenantsSlug = 'tenants'
export const ssrPagesSlug = 'ssr'
export const customLivePreviewSlug = 'custom-live-preview'
export const ssrAutosavePagesSlug = 'ssr-autosave'
export const postsSlug = 'posts'
export const mediaSlug = 'media'