diff --git a/packages/next/src/views/Document/renderDocumentSlots.tsx b/packages/next/src/views/Document/renderDocumentSlots.tsx index 357ae2681..fdd3a56df 100644 --- a/packages/next/src/views/Document/renderDocumentSlots.tsx +++ b/packages/next/src/views/Document/renderDocumentSlots.tsx @@ -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 diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index ff3792db1..ad7ef84aa 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -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 diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 53935cc27..9f03d4032 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -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' diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index eac1f5ee1..62dd23197 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -56,6 +56,7 @@ export function DefaultEditView({ BeforeDocumentControls, Description, EditMenuItems, + LivePreview: CustomLivePreview, PreviewButton, PublishButton, SaveButton, @@ -694,7 +695,11 @@ export function DefaultEditView({ {AfterDocument} {isLivePreviewEnabled && !isInDrawer && livePreviewURL && ( - + <> + {CustomLivePreview || ( + + )} + )} diff --git a/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/RefreshRouteOnSave.tsx b/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/RefreshRouteOnSave.tsx new file mode 100644 index 000000000..046ec440b --- /dev/null +++ b/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/RefreshRouteOnSave.tsx @@ -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 router.refresh()} serverURL={PAYLOAD_SERVER_URL} /> +} diff --git a/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/page.tsx b/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/page.tsx new file mode 100644 index 000000000..893b6cf98 --- /dev/null +++ b/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/page.tsx @@ -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({ + slug, + collection: customLivePreviewSlug, + draft: true, + }) + + if (!data) { + notFound() + } + + return ( + + + + + +
{`For Testing: ${data.title}`}
+
+
+ ) +} + +export async function generateStaticParams() { + process.env.PAYLOAD_DROP_DATABASE = 'false' + try { + const ssrPages = await getDocs(customLivePreviewSlug) + return ssrPages?.map((page) => { + return { slug: page.slug } + }) + } catch (_err) { + return [] + } +} diff --git a/test/live-preview/collections/CustomLivePreview.ts b/test/live-preview/collections/CustomLivePreview.ts new file mode 100644 index 000000000..b0d917866 --- /dev/null +++ b/test/live-preview/collections/CustomLivePreview.ts @@ -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, + }, + ], + }, + ], +} diff --git a/test/live-preview/components/CustomLivePreview.tsx b/test/live-preview/components/CustomLivePreview.tsx new file mode 100644 index 000000000..3cda3ead1 --- /dev/null +++ b/test/live-preview/components/CustomLivePreview.tsx @@ -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 ( +
+ {isLivePreviewing && ( + <> +

Custom live preview being rendered

+ + + )} +
+ ) +} diff --git a/test/live-preview/components/styles.css b/test/live-preview/components/styles.css new file mode 100644 index 000000000..0089516a0 --- /dev/null +++ b/test/live-preview/components/styles.css @@ -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%; + } +} diff --git a/test/live-preview/config.ts b/test/live-preview/config.ts index 106343fc9..6631fd91a 100644 --- a/test/live-preview/config.ts +++ b/test/live-preview/config.ts @@ -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], diff --git a/test/live-preview/e2e.spec.ts b/test/live-preview/e2e.spec.ts index b05f5954b..a913ff82d 100644 --- a/test/live-preview/e2e.spec.ts +++ b/test/live-preview/e2e.spec.ts @@ -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 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') + }) }) diff --git a/test/live-preview/payload-types.ts b/test/live-preview/payload-types.ts index 7c83947d4..b89380fcb 100644 --- a/test/live-preview/payload-types.ts +++ b/test/live-preview/payload-types.ts @@ -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 | MediaSelect; 'collection-level-config': CollectionLevelConfigSelect | CollectionLevelConfigSelect; 'static-url': StaticUrlSelect | StaticUrlSelect; + 'custom-live-preview': CustomLivePreviewSelect | CustomLivePreviewSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -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 { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "custom-live-preview_select". + */ +export interface CustomLivePreviewSelect { + 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". diff --git a/test/live-preview/seed/index.ts b/test/live-preview/seed/index.ts index 4fd65fe5a..66d46ec5e 100644 --- a/test/live-preview/seed/index.ts +++ b/test/live-preview/seed/index.ts @@ -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: { diff --git a/test/live-preview/shared.ts b/test/live-preview/shared.ts index fef101723..2d945e7f6 100644 --- a/test/live-preview/shared.ts +++ b/test/live-preview/shared.ts @@ -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'