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

View File

@@ -10,7 +10,8 @@ export const Pages: CollectionConfig = {
useAsTitle: 'title', useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'], defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: { 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: { access: {

View File

@@ -58,13 +58,16 @@ const Preview: React.FC<
const values = reduceFieldsToValues(fields, true) const values = reduceFieldsToValues(fields, true)
// TODO: only send `fieldSchemaToJSON` one time // 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 // external window
if (isPopupOpen) { if (isPopupOpen) {
setIframeHasLoaded(false) setIframeHasLoaded(false)
if (popupRef.current) {
if (popupHasLoaded && popupRef.current) {
popupRef.current.postMessage(message, url) popupRef.current.postMessage(message, url)
} }
} }

View File

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

View File

@@ -14,28 +14,29 @@ export interface PopupMessage {
export const usePopupWindow = (props: { export const usePopupWindow = (props: {
eventType?: string eventType?: string
href: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
onMessage?: (searchParams: PopupMessage['searchParams']) => Promise<void> onMessage?: (searchParams: PopupMessage['searchParams']) => Promise<void>
url: string
}): { }): {
isPopupOpen: boolean isPopupOpen: boolean
openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void
popupHasLoaded: boolean popupHasLoaded: boolean
popupRef?: React.MutableRefObject<Window | null> popupRef?: React.MutableRefObject<Window | null>
} => { } => {
const { eventType, href, onMessage } = props const { eventType, onMessage, url } = props
const isReceivingMessage = useRef(false) const isReceivingMessage = useRef(false)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [popupHasLoaded, setPopupHasLoaded] = useState(false) const [popupHasLoaded, setPopupHasLoaded] = useState(false)
const { serverURL } = useConfig() const { serverURL } = useConfig()
const popupRef = useRef<Window | null>(null) const popupRef = useRef<Window | null>(null)
const hasAttachedMessageListener = useRef(false)
// Optionally broadcast messages back out to the parent component // Optionally broadcast messages back out to the parent component
useEffect(() => { useEffect(() => {
const receiveMessage = async (event: MessageEvent): Promise<void> => { const receiveMessage = async (event: MessageEvent): Promise<void> => {
if ( if (
event.origin !== window.location.origin || event.origin !== window.location.origin ||
event.origin !== href || event.origin !== url ||
event.origin !== serverURL event.origin !== serverURL
) { ) {
// console.warn(`Message received by ${event.origin}; IGNORED.`) // eslint-disable-line no-console // 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) window.addEventListener('message', receiveMessage, false)
}
return () => { return () => {
window.removeEventListener('message', receiveMessage) window.removeEventListener('message', receiveMessage)
} }
}, [onMessage, eventType, href, serverURL]) }, [onMessage, eventType, url, serverURL, isOpen])
// Customize the size, position, and style of the popup window // Customize the size, position, and style of the popup window
const openPopupWindow = useCallback( const openPopupWindow = useCallback(
@@ -93,23 +96,36 @@ export const usePopupWindow = (props: {
return strCopy return strCopy
}, '') }, '')
.slice(0, -1) // remove last ',' (comma) .slice(0, -1) // remove last ',' (comma)
const newWindow = window.open(href, '_blank', popupOptions)
const newWindow = window.open(url, '_blank', popupOptions)
popupRef.current = newWindow popupRef.current = newWindow
setIsOpen(true) setIsOpen(true)
}, },
[href], [url],
) )
// the only cross-origin way of detecting when a popup window has loaded // 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 // 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 // there is no way in js to add an event listener to a popup window across domains
useEffect(() => { useEffect(() => {
if (hasAttachedMessageListener.current) return
hasAttachedMessageListener.current = true
window.addEventListener('message', (event) => { 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) 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 // 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 // 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 // The Live Preview config is inherited from the top down
url: ({ data, documentInfo }) => url: ({ data, documentInfo }) =>
`http://localhost:3001${ `http://localhost:3001${
documentInfo.slug !== 'pages' ? `/${documentInfo.slug}` : '' documentInfo?.slug && documentInfo.slug !== 'pages' ? `/${documentInfo.slug}` : ''
}/${data?.slug}`, }${data?.slug && data.slug !== 'home' ? `/${data.slug}` : ''}`,
breakpoints: [ breakpoints: [
{ {
label: 'Mobile', 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 { Post } from '../../../../payload-types'
import { fetchDoc } from '../../../_api/fetchDoc' import { fetchDoc } from '../../../_api/fetchDoc'
import { fetchDocs } from '../../../_api/fetchDocs' import { fetchDocs } from '../../../_api/fetchDocs'
import { Blocks } from '../../../_components/Blocks' import { PostClient } from './page.client'
import { PostHero } from '../../../_heros/PostHero'
export default async function Post(args: {
params: {
slug: string
}
}) {
const {
params: { slug = 'home' },
} = args
export default async function Post({ params: { slug = '' } }) {
let post: Post | null = null let post: Post | null = null
try { try {
@@ -31,55 +22,7 @@ export default async function Post(args: {
notFound() notFound()
} }
const { layout, relatedPosts } = post return <PostClient post={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>
)
} }
export async function generateStaticParams() { export async function generateStaticParams() {

View File

@@ -24,10 +24,6 @@
} }
} }
.title {
margin: 0;
}
.warning { .warning {
margin-bottom: calc(var(--base) * 1.5); 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<{ export const PostHero: React.FC<{
post: Post post: Post
}> = ({ post }) => { }> = ({ post }) => {
const { id, title, meta: { image: metaImage, description } = {}, createdAt } = post const { id, meta: { image: metaImage, description } = {}, createdAt } = post
return ( return (
<Fragment> <Fragment>
<Gutter className={classes.postHero}> <Gutter className={classes.postHero}>
<div className={classes.content}> <div className={classes.content}>
<h1 className={classes.title}>{title}</h1> <RichText content={post?.hero?.richText} className={classes.richText} />
<p className={classes.meta}> <p className={classes.meta}>
{createdAt && ( {createdAt && (
<Fragment> <Fragment>