fix: properly handles nested routes for live preview (#3586)

This commit is contained in:
Jacob Fletcher
2023-10-12 12:45:39 -04:00
committed by GitHub
parent 32c0bef05e
commit 64864686c4
10 changed files with 110 additions and 85 deletions

View File

@@ -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` 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.
{/* IMAGE OF LIVE PREVIEW HERE */}
@@ -84,7 +84,7 @@ Here is an example of using a function that returns a dynamic URL:
documentInfo,
locale
}) => `${data.tenant.url}${ // Multi-tenant top-level domain
documentInfo.slug === 'posts' ? `/posts/${data.slug}` : `/${data.slug}
documentInfo.slug === 'posts' ? `/posts/${data.slug}` : `${data.slug !== 'home' : `/${data.slug}` : ''}`
`}?locale=${locale}`, // Localization query param
collections: ['pages'],
},

View File

@@ -10,7 +10,8 @@ export const Pages: CollectionConfig = {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data }) => `${process.env.PAYLOAD_PUBLIC_SITE_URL}/${data.slug}`,
url: ({ data }) =>
`${process.env.PAYLOAD_PUBLIC_SITE_URL}${data.slug !== 'home' ? `/${data.slug}` : ''}`,
},
},
access: {

View File

@@ -58,13 +58,16 @@ const Preview: React.FC<
const values = reduceFieldsToValues(fields, true)
// TODO: only send `fieldSchemaToJSON` one time
const message = JSON.stringify({ data: values, fieldSchemaJSON, type: 'livePreview' })
const message = JSON.stringify({
data: values,
fieldSchemaJSON,
type: 'payload-live-preview',
})
// external window
if (isPopupOpen) {
setIframeHasLoaded(false)
if (popupHasLoaded && popupRef.current) {
if (popupRef.current) {
popupRef.current.postMessage(message, url)
}
}

View File

@@ -55,8 +55,8 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
: livePreviewConfig?.url
const popupState = usePopupWindow({
eventType: 'livePreview',
href: url,
eventType: 'payload-live-preview',
url,
})
const { apiURL, data, permissions } = props

View File

@@ -14,28 +14,29 @@ export interface PopupMessage {
export const usePopupWindow = (props: {
eventType?: string
href: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMessage?: (searchParams: PopupMessage['searchParams']) => Promise<void>
url: string
}): {
isPopupOpen: boolean
openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void
popupHasLoaded: boolean
popupRef?: React.MutableRefObject<Window | null>
} => {
const { eventType, href, onMessage } = props
const { eventType, onMessage, url } = props
const isReceivingMessage = useRef(false)
const [isOpen, setIsOpen] = useState(false)
const [popupHasLoaded, setPopupHasLoaded] = useState(false)
const { serverURL } = useConfig()
const popupRef = useRef<Window | null>(null)
const hasAttachedMessageListener = useRef(false)
// Optionally broadcast messages back out to the parent component
useEffect(() => {
const receiveMessage = async (event: MessageEvent): Promise<void> => {
if (
event.origin !== window.location.origin ||
event.origin !== href ||
event.origin !== url ||
event.origin !== serverURL
) {
// console.warn(`Message received by ${event.origin}; IGNORED.`) // eslint-disable-line no-console
@@ -53,12 +54,14 @@ export const usePopupWindow = (props: {
}
}
if (isOpen && popupRef.current) {
window.addEventListener('message', receiveMessage, false)
}
return () => {
window.removeEventListener('message', receiveMessage)
}
}, [onMessage, eventType, href, serverURL])
}, [onMessage, eventType, url, serverURL, isOpen])
// Customize the size, position, and style of the popup window
const openPopupWindow = useCallback(
@@ -93,23 +96,36 @@ export const usePopupWindow = (props: {
return strCopy
}, '')
.slice(0, -1) // remove last ',' (comma)
const newWindow = window.open(href, '_blank', popupOptions)
const newWindow = window.open(url, '_blank', popupOptions)
popupRef.current = newWindow
setIsOpen(true)
},
[href],
[url],
)
// the only cross-origin way of detecting when a popup window has loaded
// we catch a message event that the site rendered within the popup window fires
// there is no way in js to add an event listener to a popup window across domains
useEffect(() => {
if (hasAttachedMessageListener.current) return
hasAttachedMessageListener.current = true
window.addEventListener('message', (event) => {
if (event.origin === href && event.data === 'ready') {
const data = JSON.parse(event.data)
if (
url.startsWith(event.origin) &&
data.type === eventType &&
data.popupReady &&
!popupHasLoaded
) {
setPopupHasLoaded(true)
}
})
}, [href])
}, [url, eventType, popupHasLoaded])
// this is the most stable and widely supported way to check if a popup window is no longer open
// we poll its ref every x ms and use the popup window's `closed` property

View File

@@ -25,8 +25,8 @@ export default buildConfigWithDefaults({
// The Live Preview config is inherited from the top down
url: ({ data, documentInfo }) =>
`http://localhost:3001${
documentInfo.slug !== 'pages' ? `/${documentInfo.slug}` : ''
}/${data?.slug}`,
documentInfo?.slug && documentInfo.slug !== 'pages' ? `/${documentInfo.slug}` : ''
}${data?.slug && data.slug !== 'home' ? `/${data.slug}` : ''}`,
breakpoints: [
{
label: 'Mobile',

View File

@@ -0,0 +1,66 @@
'use client'
import { Post as PostType } from '@/payload-types'
import { useLivePreview } from '../../../../../../../packages/live-preview-react'
import React from 'react'
import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
import { Blocks } from '@/app/_components/Blocks'
import { PostHero } from '@/app/_heros/PostHero'
export const PostClient: React.FC<{
post: PostType
}> = ({ post: initialPost }) => {
const { data } = useLivePreview<PostType>({
initialData: initialPost,
serverURL: PAYLOAD_SERVER_URL,
depth: 2,
})
return (
<React.Fragment>
<PostHero post={data} />
<Blocks blocks={data?.layout} />
<Blocks
disableTopPadding
blocks={[
{
blockType: 'relatedPosts',
blockName: 'Related Posts',
relationTo: 'posts',
introContent: [
{
type: 'h4',
children: [
{
text: 'Related posts',
},
],
},
{
type: 'p',
children: [
{
text: 'The posts displayed here are individually selected for this page. Admins can select any number of related posts to display here and the layout will adjust accordingly. Alternatively, you could swap this out for the "Archive" block to automatically populate posts by category complete with pagination. To manage related posts, ',
},
{
type: 'link',
url: `/admin/collections/posts/${data?.id}`,
children: [
{
text: 'navigate to the admin dashboard',
},
],
},
{
text: '.',
},
],
},
],
docs: data?.relatedPosts,
},
]}
/>
</React.Fragment>
)
}

View File

@@ -4,18 +4,9 @@ import { notFound } from 'next/navigation'
import { Post } from '../../../../payload-types'
import { fetchDoc } from '../../../_api/fetchDoc'
import { fetchDocs } from '../../../_api/fetchDocs'
import { Blocks } from '../../../_components/Blocks'
import { PostHero } from '../../../_heros/PostHero'
export default async function Post(args: {
params: {
slug: string
}
}) {
const {
params: { slug = 'home' },
} = args
import { PostClient } from './page.client'
export default async function Post({ params: { slug = '' } }) {
let post: Post | null = null
try {
@@ -31,55 +22,7 @@ export default async function Post(args: {
notFound()
}
const { layout, relatedPosts } = post
return (
<React.Fragment>
<PostHero post={post} />
<Blocks blocks={layout} />
<Blocks
disableTopPadding
blocks={[
{
blockType: 'relatedPosts',
blockName: 'Related Posts',
relationTo: 'posts',
introContent: [
{
type: 'h4',
children: [
{
text: 'Related posts',
},
],
},
{
type: 'p',
children: [
{
text: 'The posts displayed here are individually selected for this page. Admins can select any number of related posts to display here and the layout will adjust accordingly. Alternatively, you could swap this out for the "Archive" block to automatically populate posts by category complete with pagination. To manage related posts, ',
},
{
type: 'link',
url: `/admin/collections/posts/${post.id}`,
children: [
{
text: 'navigate to the admin dashboard',
},
],
},
{
text: '.',
},
],
},
],
docs: relatedPosts,
},
]}
/>
</React.Fragment>
)
return <PostClient post={post} />
}
export async function generateStaticParams() {

View File

@@ -24,10 +24,6 @@
}
}
.title {
margin: 0;
}
.warning {
margin-bottom: calc(var(--base) * 1.5);
}

View File

@@ -13,13 +13,13 @@ import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
export const PostHero: React.FC<{
post: Post
}> = ({ post }) => {
const { id, title, meta: { image: metaImage, description } = {}, createdAt } = post
const { id, meta: { image: metaImage, description } = {}, createdAt } = post
return (
<Fragment>
<Gutter className={classes.postHero}>
<div className={classes.content}>
<h1 className={classes.title}>{title}</h1>
<RichText content={post?.hero?.richText} className={classes.richText} />
<p className={classes.meta}>
{createdAt && (
<Fragment>