feat(next): regenerate live preview url on save (#13631)

Closes #12785.

Although your live preview URL can be dynamic based on document data, it
is never recalculated after initial mount. This means if your URL is
dependent of document data that was just changed, such as a "slug"
field, the URL of the iframe does not reflect that change as expected
until the window is refreshed or you navigate back.

This also means that server-side live preview will crash when your
front-end attempts to query using a slug that no longer exists. Here's
the general flow: slug changes, autosave runs, iframe refreshes (url has
old slug), 404.

Now, we execute your live preview function on submit within form state,
and the window responds to the new URL as expected, refreshing itself
without losing its connection.

Here's the result:


https://github.com/user-attachments/assets/7dd3b147-ab6c-4103-8b2f-14d6bc889625

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211094211063140
This commit is contained in:
Jacob Fletcher
2025-09-23 09:37:15 -04:00
committed by GitHub
parent 5b5eaebfdf
commit 00a673e491
26 changed files with 573 additions and 237 deletions

View File

@@ -72,6 +72,7 @@ export const initReq = async function ({
cookies, cookies,
headers, headers,
}) })
const i18n: I18nClient = await initI18n({ const i18n: I18nClient = await initI18n({
config: config.i18n, config: config.i18n,
context: 'client', context: 'client',

View File

@@ -6,7 +6,6 @@ import type {
DocumentViewServerProps, DocumentViewServerProps,
DocumentViewServerPropsOnly, DocumentViewServerPropsOnly,
EditViewComponent, EditViewComponent,
LivePreviewConfig,
PayloadComponent, PayloadComponent,
RenderDocumentVersionsProperties, RenderDocumentVersionsProperties,
} from 'payload' } from 'payload'
@@ -18,6 +17,7 @@ import {
LivePreviewProvider, LivePreviewProvider,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { handleLivePreview } from '@payloadcms/ui/rsc'
import { isEditing as getIsEditing } from '@payloadcms/ui/shared' import { isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import { notFound, redirect } from 'next/navigation.js' import { notFound, redirect } from 'next/navigation.js'
@@ -348,36 +348,14 @@ export const renderDocument = async ({
viewType, viewType,
} }
const isLivePreviewEnabled = Boolean( const { isLivePreviewEnabled, livePreviewConfig, livePreviewURL } = await handleLivePreview({
config.admin?.livePreview?.collections?.includes(collectionSlug) || collectionSlug,
config.admin?.livePreview?.globals?.includes(globalSlug) || config,
collectionConfig?.admin?.livePreview || data: doc,
globalConfig?.admin?.livePreview, globalSlug,
) operation,
req,
const livePreviewConfig: LivePreviewConfig = { })
...(isLivePreviewEnabled ? config.admin.livePreview : {}),
...(collectionConfig?.admin?.livePreview || {}),
...(globalConfig?.admin?.livePreview || {}),
}
const livePreviewURL =
operation !== 'create'
? typeof livePreviewConfig?.url === 'function'
? await livePreviewConfig.url({
collectionConfig,
data: doc,
globalConfig,
locale,
req,
/**
* @deprecated
* Use `req.payload` instead. This will be removed in the next major version.
*/
payload: initPageResult.req.payload,
})
: livePreviewConfig?.url
: ''
return { return {
data: doc, data: doc,
@@ -412,6 +390,7 @@ export const renderDocument = async ({
breakpoints={livePreviewConfig?.breakpoints} breakpoints={livePreviewConfig?.breakpoints}
isLivePreviewEnabled={isLivePreviewEnabled && operation !== 'create'} isLivePreviewEnabled={isLivePreviewEnabled && operation !== 'create'}
isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'} isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'}
typeofLivePreviewURL={typeof livePreviewConfig?.url as 'function' | 'string' | undefined}
url={livePreviewURL} url={livePreviewURL}
> >
{showHeader && !drawerSlug && ( {showHeader && !drawerSlug && (

View File

@@ -135,6 +135,12 @@ export type BuildFormStateArgs = {
*/ */
renderAllFields?: boolean renderAllFields?: boolean
req: PayloadRequest req: PayloadRequest
/**
* If true, will return a fresh URL for live preview based on the current form state.
* Note: this will run on every form state event, so if your `livePreview.url` function is long running or expensive,
* ensure it caches itself as needed.
*/
returnLivePreviewURL?: boolean
returnLockStatus?: boolean returnLockStatus?: boolean
schemaPath: string schemaPath: string
select?: SelectType select?: SelectType

View File

@@ -151,10 +151,14 @@ export type LivePreviewConfig = {
width: number | string width: number | string
}[] }[]
/** /**
The URL of the frontend application. This will be rendered within an `iframe` as its `src`. * 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. * 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. * The frontend application is responsible for receiving the message and updating the UI accordingly.
Use the `useLivePreview` hook to get started in React applications. * Use the `useLivePreview` hook to get started in React applications.
*
* 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,
* or if you need to do something more complex, cache your function as needed.
*/ */
url?: url?:
| ((args: { | ((args: {

View File

@@ -233,9 +233,7 @@ export type GlobalConfig<TSlug extends GlobalSlug = any> = {
export interface SanitizedGlobalConfig export interface SanitizedGlobalConfig
extends Omit<DeepRequired<GlobalConfig>, 'endpoints' | 'fields' | 'slug' | 'versions'> { extends Omit<DeepRequired<GlobalConfig>, 'endpoints' | 'fields' | 'slug' | 'versions'> {
endpoints: Endpoint[] | false endpoints: Endpoint[] | false
fields: Field[] fields: Field[]
/** /**
* Fields in the database schema structure * Fields in the database schema structure
* Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded * Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded

View File

@@ -6,24 +6,17 @@ import './index.scss'
const baseClass = 'live-preview-iframe' const baseClass = 'live-preview-iframe'
type Props = { export const IFrame: React.FC = () => {
ref: React.RefObject<HTMLIFrameElement> const { iframeRef, setLoadedURL, url, zoom } = useLivePreviewContext()
setIframeHasLoaded: (value: boolean) => void
url: string
}
export const IFrame: React.FC<Props> = (props) => {
const { ref, setIframeHasLoaded, url } = props
const { zoom } = useLivePreviewContext()
return ( return (
<iframe <iframe
className={baseClass} className={baseClass}
key={url}
onLoad={() => { onLoad={() => {
setIframeHasLoaded(true) setLoadedURL(url)
}} }}
ref={ref} ref={iframeRef}
src={url} src={url}
style={{ style={{
transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined, transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined,

View File

@@ -12,9 +12,9 @@ import { useLivePreviewContext } from '../../../providers/LivePreview/context.js
import { useLocale } from '../../../providers/Locale/index.js' import { useLocale } from '../../../providers/Locale/index.js'
import { ShimmerEffect } from '../../ShimmerEffect/index.js' import { ShimmerEffect } from '../../ShimmerEffect/index.js'
import { DeviceContainer } from '../Device/index.js' import { DeviceContainer } from '../Device/index.js'
import './index.scss'
import { IFrame } from '../IFrame/index.js' import { IFrame } from '../IFrame/index.js'
import { LivePreviewToolbar } from '../Toolbar/index.js' import { LivePreviewToolbar } from '../Toolbar/index.js'
import './index.scss'
const baseClass = 'live-preview-window' const baseClass = 'live-preview-window'
@@ -22,12 +22,11 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
const { const {
appIsReady, appIsReady,
breakpoint, breakpoint,
iframeHasLoaded,
iframeRef, iframeRef,
isLivePreviewing, isLivePreviewing,
loadedURL,
popupRef, popupRef,
previewWindowType, previewWindowType,
setIframeHasLoaded,
url, url,
} = useLivePreviewContext() } = useLivePreviewContext()
@@ -38,10 +37,12 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
const [formState] = useAllFormFields() const [formState] = useAllFormFields()
const { id, collectionSlug, globalSlug } = useDocumentInfo() const { id, collectionSlug, globalSlug } = useDocumentInfo()
// For client-side apps, send data through `window.postMessage` /**
// The preview could either be an iframe embedded on the page * For client-side apps, send data through `window.postMessage`
// Or it could be a separate popup window * The preview could either be an iframe embedded on the page
// We need to transmit data to both accordingly * Or it could be a separate popup window
* We need to transmit data to both accordingly
*/
useEffect(() => { useEffect(() => {
if (!isLivePreviewing || !appIsReady) { if (!isLivePreviewing || !appIsReady) {
return return
@@ -79,21 +80,22 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
url, url,
collectionSlug, collectionSlug,
globalSlug, globalSlug,
iframeHasLoaded,
id, id,
previewWindowType, previewWindowType,
popupRef, popupRef,
appIsReady, appIsReady,
iframeRef, iframeRef,
setIframeHasLoaded,
mostRecentUpdate, mostRecentUpdate,
locale, locale,
isLivePreviewing, isLivePreviewing,
loadedURL,
]) ])
// To support SSR, we transmit a `window.postMessage` event without a payload /**
// This is because the event will ultimately trigger a server-side roundtrip * To support SSR, we transmit a `window.postMessage` event without a payload
// i.e., save, save draft, autosave, etc. will fire `router.refresh()` * This is because the event will ultimately trigger a server-side roundtrip
* i.e., save, save draft, autosave, etc. will fire `router.refresh()`
*/
useEffect(() => { useEffect(() => {
if (!isLivePreviewing || !appIsReady) { if (!isLivePreviewing || !appIsReady) {
return return
@@ -114,30 +116,26 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
} }
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url, isLivePreviewing, appIsReady]) }, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url, isLivePreviewing, appIsReady])
if (previewWindowType === 'iframe') { if (previewWindowType !== 'iframe') {
return ( return null
<div }
className={[
baseClass, return (
isLivePreviewing && `${baseClass}--is-live-previewing`, <div
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`, className={[
] baseClass,
.filter(Boolean) isLivePreviewing && `${baseClass}--is-live-previewing`,
.join(' ')} breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
> ]
<div className={`${baseClass}__wrapper`}> .filter(Boolean)
<LivePreviewToolbar {...props} /> .join(' ')}
<div className={`${baseClass}__main`}> >
<DeviceContainer> <div className={`${baseClass}__wrapper`}>
{url ? ( <LivePreviewToolbar {...props} />
<IFrame ref={iframeRef} setIframeHasLoaded={setIframeHasLoaded} url={url} /> <div className={`${baseClass}__main`}>
) : ( <DeviceContainer>{url ? <IFrame /> : <ShimmerEffect height="100%" />}</DeviceContainer>
<ShimmerEffect height="100%" />
)}
</DeviceContainer>
</div>
</div> </div>
</div> </div>
) </div>
} )
} }

View File

@@ -9,6 +9,7 @@ export { CheckIcon } from '../../icons/Check/index.js'
export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js' export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js'
export { getColumns } from '../../utilities/getColumns.js' export { getColumns } from '../../utilities/getColumns.js'
export { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js' export { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js'
export { handleLivePreview } from '../../utilities/handleLivePreview.js'
export { renderFilters, renderTable } from '../../utilities/renderTable.js' export { renderFilters, renderTable } from '../../utilities/renderTable.js'
export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js' export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js'
export { upsertPreferences } from '../../utilities/upsertPreferences.js' export { upsertPreferences } from '../../utilities/upsertPreferences.js'

View File

@@ -18,13 +18,13 @@ export type Preferences = {
export type FormOnSuccess<T = unknown, C = Record<string, unknown>> = ( export type FormOnSuccess<T = unknown, C = Record<string, unknown>> = (
json: T, json: T,
options?: { ctx?: {
/** /**
* Arbitrary context passed to the onSuccess callback. * Arbitrary context passed to the onSuccess callback.
*/ */
context?: C context?: C
/** /**
* Form state at the time of the request used to retrieve the JSON response. * The form state that was sent with the request when retrieving the `json` arg.
*/ */
formState?: FormState formState?: FormState
}, },

View File

@@ -12,12 +12,17 @@ export interface LivePreviewContextType {
appIsReady: boolean appIsReady: boolean
breakpoint: LivePreviewConfig['breakpoints'][number]['name'] breakpoint: LivePreviewConfig['breakpoints'][number]['name']
breakpoints: LivePreviewConfig['breakpoints'] breakpoints: LivePreviewConfig['breakpoints']
iframeHasLoaded: boolean
iframeRef: React.RefObject<HTMLIFrameElement | null> iframeRef: React.RefObject<HTMLIFrameElement | null>
isLivePreviewEnabled: boolean isLivePreviewEnabled: boolean
isLivePreviewing: boolean isLivePreviewing: boolean
isPopupOpen: boolean isPopupOpen: boolean
listeningForMessages?: boolean listeningForMessages?: boolean
/**
* The URL that has finished loading in the iframe or popup.
* For example, if you set the `url`, it will begin to load into the iframe,
* but `loadedURL` will not be set until the iframe's `onLoad` event fires.
*/
loadedURL?: string
measuredDeviceSize: { measuredDeviceSize: {
height: number height: number
width: number width: number
@@ -28,12 +33,17 @@ export interface LivePreviewContextType {
setAppIsReady: (appIsReady: boolean) => void setAppIsReady: (appIsReady: boolean) => void
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
setHeight: (height: number) => void setHeight: (height: number) => void
setIframeHasLoaded: (loaded: boolean) => void
setIsLivePreviewing: (isLivePreviewing: boolean) => void setIsLivePreviewing: (isLivePreviewing: boolean) => void
setLoadedURL: (loadedURL: string) => void
setMeasuredDeviceSize: (size: { height: number; width: number }) => void setMeasuredDeviceSize: (size: { height: number; width: number }) => void
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
setSize: Dispatch<SizeReducerAction> setSize: Dispatch<SizeReducerAction>
setToolbarPosition: (position: { x: number; y: number }) => void setToolbarPosition: (position: { x: number; y: number }) => void
/**
* Sets the URL of the preview (either iframe or popup).
* Will trigger a reload of the window.
*/
setURL: (url: string) => void
setWidth: (width: number) => void setWidth: (width: number) => void
setZoom: (zoom: number) => void setZoom: (zoom: number) => void
size: { size: {
@@ -44,6 +54,11 @@ export interface LivePreviewContextType {
x: number x: number
y: number y: number
} }
/**
* The live preview url property can be either a string or a function that returns a string.
* 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: string | undefined
zoom: number zoom: number
} }
@@ -52,7 +67,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
appIsReady: false, appIsReady: false,
breakpoint: undefined, breakpoint: undefined,
breakpoints: undefined, breakpoints: undefined,
iframeHasLoaded: false,
iframeRef: undefined, iframeRef: undefined,
isLivePreviewEnabled: undefined, isLivePreviewEnabled: undefined,
isLivePreviewing: false, isLivePreviewing: false,
@@ -67,12 +81,13 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
setAppIsReady: () => {}, setAppIsReady: () => {},
setBreakpoint: () => {}, setBreakpoint: () => {},
setHeight: () => {}, setHeight: () => {},
setIframeHasLoaded: () => {},
setIsLivePreviewing: () => {}, setIsLivePreviewing: () => {},
setLoadedURL: () => {},
setMeasuredDeviceSize: () => {}, setMeasuredDeviceSize: () => {},
setPreviewWindowType: () => {}, setPreviewWindowType: () => {},
setSize: () => {}, setSize: () => {},
setToolbarPosition: () => {}, setToolbarPosition: () => {},
setURL: () => {},
setWidth: () => {}, setWidth: () => {},
setZoom: () => {}, setZoom: () => {},
size: { size: {
@@ -83,6 +98,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
x: 0, x: 0,
y: 0, y: 0,
}, },
typeofLivePreviewURL: undefined,
url: undefined, url: undefined,
zoom: 1, zoom: 1,
}) })

View File

@@ -4,9 +4,12 @@ import type { CollectionPreferences, LivePreviewConfig } from 'payload'
import { DndContext } from '@dnd-kit/core' import { DndContext } from '@dnd-kit/core'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { LivePreviewContextType } from './context.js'
import { usePopupWindow } from '../../hooks/usePopupWindow.js' import { usePopupWindow } from '../../hooks/usePopupWindow.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { usePreferences } from '../../providers/Preferences/index.js' import { usePreferences } from '../../providers/Preferences/index.js'
import { formatAbsoluteURL } from '../../utilities/formatAbsoluteURL.js'
import { customCollisionDetection } from './collisionDetection.js' import { customCollisionDetection } from './collisionDetection.js'
import { LivePreviewContext } from './context.js' import { LivePreviewContext } from './context.js'
import { sizeReducer } from './sizeReducer.js' import { sizeReducer } from './sizeReducer.js'
@@ -21,23 +24,15 @@ export type LivePreviewProviderProps = {
} }
isLivePreviewEnabled?: boolean isLivePreviewEnabled?: boolean
isLivePreviewing: boolean isLivePreviewing: boolean
url: string } & Pick<LivePreviewContextType, 'typeofLivePreviewURL' | 'url'>
}
const getAbsoluteUrl = (url) => {
try {
return new URL(url, window.location.origin).href
} catch {
return url
}
}
export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
breakpoints: incomingBreakpoints, breakpoints: incomingBreakpoints,
children, children,
isLivePreviewEnabled, isLivePreviewEnabled,
isLivePreviewing: incomingIsLivePreviewing, isLivePreviewing: incomingIsLivePreviewing,
url: incomingUrl, typeofLivePreviewURL,
url: urlFromProps,
}) => { }) => {
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe') const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
const [isLivePreviewing, setIsLivePreviewing] = useState(incomingIsLivePreviewing) const [isLivePreviewing, setIsLivePreviewing] = useState(incomingIsLivePreviewing)
@@ -57,19 +52,6 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const [url, setURL] = useState<string>('') const [url, setURL] = useState<string>('')
// This needs to be done in a useEffect to prevent hydration issues
// as the URL may not be absolute when passed in as a prop,
// and getAbsoluteUrl requires the window object to be available
useEffect(
() =>
setURL(
incomingUrl?.startsWith('http://') || incomingUrl?.startsWith('https://')
? incomingUrl
: getAbsoluteUrl(incomingUrl),
),
[incomingUrl],
)
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({ const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
eventType: 'payload-live-preview', eventType: 'payload-live-preview',
url, url,
@@ -86,7 +68,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const iframeRef = React.useRef<HTMLIFrameElement>(null) const iframeRef = React.useRef<HTMLIFrameElement>(null)
const [iframeHasLoaded, setIframeHasLoaded] = useState(false) const [loadedURL, setLoadedURL] = useState<string>()
const [zoom, setZoom] = useState(1) const [zoom, setZoom] = useState(1)
@@ -102,6 +84,31 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const [breakpoint, setBreakpoint] = const [breakpoint, setBreakpoint] =
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive') React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
/**
* A "middleware" callback fn that does some additional work before `setURL`.
* This is what we provide through context, bc it:
* - ensures the URL is absolute
* - resets `appIsReady` to `false` while the new URL is loading
*/
const setLivePreviewURL = useCallback<LivePreviewContextType['setURL']>(
(_incomingURL) => {
const incomingURL = formatAbsoluteURL(_incomingURL)
if (incomingURL !== url) {
setAppIsReady(false)
setURL(incomingURL)
}
},
[url],
)
/**
* `url` needs to be relative to the window, which cannot be done on initial render.
*/
useEffect(() => {
setURL(formatAbsoluteURL(urlFromProps))
}, [urlFromProps])
// The toolbar needs to freely drag and drop around the page // The toolbar needs to freely drag and drop around the page
const handleDragEnd = (ev) => { const handleDragEnd = (ev) => {
// only update position if the toolbar is completely within the preview area // only update position if the toolbar is completely within the preview area
@@ -155,10 +162,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
} }
}, [breakpoint, breakpoints]) }, [breakpoint, breakpoints])
// Receive the `ready` message from the popup window /**
// This indicates that the app is ready to receive `window.postMessage` events * Receive the `ready` message from the popup window
// This is also the only cross-origin way of detecting when a popup window has loaded * This indicates that the app is ready to receive `window.postMessage` events
// Unlike iframe elements which have an `onLoad` handler, there is no way to access `window.open` on popups * This is also the only cross-origin way of detecting when a popup window has loaded
* Unlike iframe elements which have an `onLoad` handler, there is no way to access `window.open` on popups
*/
useEffect(() => { useEffect(() => {
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if ( if (
@@ -224,12 +233,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
appIsReady, appIsReady,
breakpoint, breakpoint,
breakpoints, breakpoints,
iframeHasLoaded,
iframeRef, iframeRef,
isLivePreviewEnabled, isLivePreviewEnabled,
isLivePreviewing, isLivePreviewing,
isPopupOpen, isPopupOpen,
listeningForMessages, listeningForMessages,
loadedURL,
measuredDeviceSize, measuredDeviceSize,
openPopupWindow, openPopupWindow,
popupRef, popupRef,
@@ -237,16 +246,18 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
setAppIsReady, setAppIsReady,
setBreakpoint, setBreakpoint,
setHeight, setHeight,
setIframeHasLoaded,
setIsLivePreviewing, setIsLivePreviewing,
setLoadedURL,
setMeasuredDeviceSize, setMeasuredDeviceSize,
setPreviewWindowType: handleWindowChange, setPreviewWindowType: handleWindowChange,
setSize, setSize,
setToolbarPosition: setPosition, setToolbarPosition: setPosition,
setURL: setLivePreviewURL,
setWidth, setWidth,
setZoom, setZoom,
size, size,
toolbarPosition: position, toolbarPosition: position,
typeofLivePreviewURL,
url, url,
zoom, zoom,
}} }}

View File

@@ -16,6 +16,7 @@ import { getClientConfig } from './getClientConfig.js'
import { getClientSchemaMap } from './getClientSchemaMap.js' import { getClientSchemaMap } from './getClientSchemaMap.js'
import { getSchemaMap } from './getSchemaMap.js' import { getSchemaMap } from './getSchemaMap.js'
import { handleFormStateLocking } from './handleFormStateLocking.js' import { handleFormStateLocking } from './handleFormStateLocking.js'
import { handleLivePreview } from './handleLivePreview.js'
export type LockedState = { export type LockedState = {
isLocked: boolean isLocked: boolean
@@ -27,11 +28,13 @@ type BuildFormStateSuccessResult = {
clientConfig?: ClientConfig clientConfig?: ClientConfig
errors?: never errors?: never
indexPath?: string indexPath?: string
livePreviewURL?: string
lockedState?: LockedState lockedState?: LockedState
state: FormState state: FormState
} }
type BuildFormStateErrorResult = { type BuildFormStateErrorResult = {
livePreviewURL?: never
lockedState?: never lockedState?: never
state?: never state?: never
} & ( } & (
@@ -95,6 +98,7 @@ export const buildFormState = async (
payload, payload,
payload: { config }, payload: { config },
}, },
returnLivePreviewURL,
returnLockStatus, returnLockStatus,
schemaPath = collectionSlug || globalSlug, schemaPath = collectionSlug || globalSlug,
select, select,
@@ -229,8 +233,26 @@ export const buildFormState = async (
}) })
} }
return { const res: BuildFormStateSuccessResult = {
lockedState: lockedStateResult, lockedState: lockedStateResult,
state: formStateResult, state: formStateResult,
} }
if (returnLivePreviewURL) {
const { livePreviewURL } = await handleLivePreview({
collectionSlug,
config,
data,
globalSlug,
req,
})
// Important: only set this when not undefined,
// Otherwise it will travel through the network as `$undefined`
if (livePreviewURL) {
res.livePreviewURL = livePreviewURL
}
}
return res
} }

View File

@@ -0,0 +1,18 @@
const getAbsoluteUrl = (url) => {
try {
return new URL(url, window.location.origin).href
} catch {
return url
}
}
/**
* Ensures the provided URL is absolute. If not, it converts it to an absolute URL based
* on the current window location.
* Note: This MUST be called within the client environment as it relies on the `window` object
* to determine the absolute URL.
*/
export const formatAbsoluteURL = (incomingURL: string) =>
incomingURL?.startsWith('http://') || incomingURL?.startsWith('https://')
? incomingURL
: getAbsoluteUrl(incomingURL)

View File

@@ -0,0 +1,137 @@
import type {
CollectionConfig,
GlobalConfig,
LivePreviewConfig,
Operation,
PayloadRequest,
SanitizedConfig,
} from 'payload'
export const getLivePreviewConfig = ({
collectionConfig,
config,
globalConfig,
isLivePreviewEnabled,
}: {
collectionConfig?: CollectionConfig
config: SanitizedConfig
globalConfig?: GlobalConfig
isLivePreviewEnabled: boolean
}) => ({
...(isLivePreviewEnabled ? config.admin.livePreview : {}),
...(collectionConfig?.admin?.livePreview || {}),
...(globalConfig?.admin?.livePreview || {}),
})
/**
* Multi-level check to determine whether live preview is enabled on a collection or global.
* For example, live preview can be enabled at both the root config level, or on the entity's config.
* If a collectionConfig/globalConfig is provided, checks if it is enabled at the root level,
* or on the entity's own config.
*/
export const isLivePreviewEnabled = ({
collectionConfig,
config,
globalConfig,
}: {
collectionConfig?: CollectionConfig
config: SanitizedConfig
globalConfig?: GlobalConfig
}): boolean => {
if (globalConfig) {
return Boolean(
config.admin?.livePreview?.globals?.includes(globalConfig.slug) ||
globalConfig.admin?.livePreview,
)
}
if (collectionConfig) {
return Boolean(
config.admin?.livePreview?.collections?.includes(collectionConfig.slug) ||
collectionConfig.admin?.livePreview,
)
}
}
/**
* 1. Looks up the relevant live preview config, which could have been enabled:
* a. At the root level, e.g. `collections: ['posts']`
* b. On the collection or global config, e.g. `admin: { livePreview: { ... } }`
* 2. Determines if live preview is enabled, and if not, early returns.
* 3. Merges the config with the root config, if necessary.
* 4. Executes the `url` function, if necessary.
*
* Notice: internal function only. Subject to change at any time. Use at your own discretion.
*/
export const handleLivePreview = async ({
collectionSlug,
config,
data,
globalSlug,
operation,
req,
}: {
collectionSlug?: string
config: SanitizedConfig
data: Record<string, unknown>
globalSlug?: string
operation?: Operation
req: PayloadRequest
}): Promise<{
isLivePreviewEnabled?: boolean
livePreviewConfig?: LivePreviewConfig
livePreviewURL?: string
}> => {
const collectionConfig = collectionSlug
? req.payload.collections[collectionSlug]?.config
: undefined
const globalConfig = globalSlug ? config.globals.find((g) => g.slug === globalSlug) : undefined
const enabled = isLivePreviewEnabled({
collectionConfig,
config,
globalConfig,
})
if (!enabled) {
return {}
}
const livePreviewConfig = getLivePreviewConfig({
collectionConfig,
config,
globalConfig,
isLivePreviewEnabled: enabled,
})
let livePreviewURL: string | undefined
if (typeof livePreviewConfig?.url === 'string') {
livePreviewURL = livePreviewConfig.url
}
if (typeof livePreviewConfig?.url === 'function' && operation !== 'create') {
try {
const result = await livePreviewConfig.url({
collectionConfig,
data,
globalConfig,
locale: { code: req.locale, label: '' },
payload: req.payload,
req,
})
if (typeof result === 'string') {
livePreviewURL = result
}
} catch (err) {
req.payload.logger.error({
err,
msg: `There was an error executing the live preview URL function for ${collectionSlug || globalSlug}`,
})
}
}
return { isLivePreviewEnabled: enabled, livePreviewConfig, livePreviewURL }
}

View File

@@ -134,7 +134,13 @@ export function DefaultEditView({
const { resetUploadEdits } = useUploadEdits() const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions() const { getFormState } = useServerFunctions()
const { startRouteTransition } = useRouteTransition() const { startRouteTransition } = useRouteTransition()
const { isLivePreviewEnabled, isLivePreviewing, previewWindowType } = useLivePreviewContext() const {
isLivePreviewEnabled,
isLivePreviewing,
previewWindowType,
setURL: setLivePreviewURL,
typeofLivePreviewURL,
} = useLivePreviewContext()
const abortOnChangeRef = useRef<AbortController>(null) const abortOnChangeRef = useRef<AbortController>(null)
const abortOnSaveRef = useRef<AbortController>(null) const abortOnSaveRef = useRef<AbortController>(null)
@@ -263,8 +269,8 @@ export function DefaultEditView({
]) ])
const onSave: FormOnSuccess<any, OnSaveContext> = useCallback( const onSave: FormOnSuccess<any, OnSaveContext> = useCallback(
async (json, options) => { async (json, ctx) => {
const { context, formState } = options || {} const { context, formState } = ctx || {}
const controller = handleAbortRef(abortOnSaveRef) const controller = handleAbortRef(abortOnSaveRef)
@@ -272,12 +278,6 @@ export function DefaultEditView({
const updatedAt = document?.updatedAt || new Date().toISOString() const updatedAt = document?.updatedAt || new Date().toISOString()
reportUpdate({
id,
entitySlug,
updatedAt,
})
// If we're editing the doc of the logged-in user, // If we're editing the doc of the logged-in user,
// Refresh the cookie to get new permissions // Refresh the cookie to get new permissions
if (user && collectionSlug === userSlug && id === user.id) { if (user && collectionSlug === userSlug && id === user.id) {
@@ -328,7 +328,7 @@ export function DefaultEditView({
if (id || globalSlug) { if (id || globalSlug) {
const docPreferences = await getDocPreferences() const docPreferences = await getDocPreferences()
const { state } = await getFormState({ const { livePreviewURL, state } = await getFormState({
id, id,
collectionSlug, collectionSlug,
data: document, data: document,
@@ -338,6 +338,7 @@ export function DefaultEditView({
globalSlug, globalSlug,
operation, operation,
renderAllFields: false, renderAllFields: false,
returnLivePreviewURL: isLivePreviewEnabled && typeofLivePreviewURL === 'function',
returnLockStatus: false, returnLockStatus: false,
schemaPath: schemaPathSegments.join('.'), schemaPath: schemaPathSegments.join('.'),
signal: controller.signal, signal: controller.signal,
@@ -349,6 +350,16 @@ export function DefaultEditView({
setDocumentIsLocked(false) setDocumentIsLocked(false)
} }
if (isLivePreviewEnabled) {
setLivePreviewURL(livePreviewURL)
}
reportUpdate({
id,
entitySlug,
updatedAt,
})
abortOnSaveRef.current = null abortOnSaveRef.current = null
return state return state
@@ -367,7 +378,7 @@ export function DefaultEditView({
isEditing, isEditing,
depth, depth,
redirectAfterCreate, redirectAfterCreate,
getDocPermissions, setLivePreviewURL,
globalSlug, globalSlug,
refreshCookieAsync, refreshCookieAsync,
incrementVersionCount, incrementVersionCount,
@@ -376,10 +387,13 @@ export function DefaultEditView({
startRouteTransition, startRouteTransition,
router, router,
resetUploadEdits, resetUploadEdits,
getDocPermissions,
getDocPreferences, getDocPreferences,
getFormState, getFormState,
docPermissions, docPermissions,
operation, operation,
isLivePreviewEnabled,
typeofLivePreviewURL,
schemaPathSegments, schemaPathSegments,
isLockingEnabled, isLockingEnabled,
setDocumentIsLocked, setDocumentIsLocked,
@@ -432,17 +446,17 @@ export function DefaultEditView({
return state return state
}, },
[ [
id, editSessionStartTime,
collectionSlug, isLockingEnabled,
getDocPreferences, getDocPreferences,
getFormState, getFormState,
id,
collectionSlug,
docPermissions,
globalSlug, globalSlug,
handleDocumentLocking,
isLockingEnabled,
operation, operation,
schemaPathSegments, schemaPathSegments,
docPermissions, handleDocumentLocking,
editSessionStartTime,
], ],
) )

View File

@@ -20,7 +20,11 @@ export default withBundleAnalyzer(
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
images: { images: {
domains: ['localhost'], remotePatterns: [
{
hostname: 'localhost',
},
],
}, },
env: { env: {
PAYLOAD_CORE_DEV: 'true', PAYLOAD_CORE_DEV: 'true',

View File

@@ -0,0 +1,3 @@
export { selectLivePreviewBreakpoint } from './selectLivePreviewBreakpoint.js'
export { selectLivePreviewZoom } from './selectLivePreviewZoom.js'
export { toggleLivePreview } from './toggleLivePreview.js'

View File

@@ -0,0 +1,29 @@
import type { Page } from 'playwright'
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
import { expect } from 'playwright/test'
export const selectLivePreviewBreakpoint = async (page: Page, breakpointLabel: string) => {
const breakpointSelector = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button',
)
await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await breakpointSelector.first().click()
await page
.locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`)
.filter({ hasText: breakpointLabel })
.click()
await expect(breakpointSelector).toContainText(breakpointLabel)
const option = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected',
)
await expect(option).toHaveText(breakpointLabel)
}

View File

@@ -0,0 +1,33 @@
import type { Page } from 'playwright'
import { exactText } from 'helpers.js'
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
import { expect } from 'playwright/test'
export const selectLivePreviewZoom = async (page: Page, zoomLabel: string) => {
const zoomSelector = page.locator('.live-preview-toolbar-controls__zoom button.popup-button')
await expect(() => expect(zoomSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await zoomSelector.first().click()
const zoomOption = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button',
{
hasText: exactText(zoomLabel),
},
)
expect(zoomOption).toBeTruthy()
await zoomOption.click()
await expect(zoomSelector).toContainText(zoomLabel)
const option = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button--selected',
)
await expect(option).toHaveText(zoomLabel)
}

View File

@@ -0,0 +1,29 @@
import type { Page } from 'playwright'
import { expect } from 'playwright/test'
export const toggleLivePreview = async (
page: Page,
options?: {
targetState?: 'off' | 'on'
},
): Promise<void> => {
const toggler = page.locator('#live-preview-toggler')
await expect(toggler).toBeVisible()
const isActive = await toggler.evaluate((el) =>
el.classList.contains('live-preview-toggler--active'),
)
if (isActive && (options?.targetState === 'off' || !options?.targetState)) {
await toggler.click()
await expect(toggler).not.toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
}
if (!isActive && (options?.targetState === 'on' || !options?.targetState)) {
await toggler.click()
await expect(toggler).toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
}
}

View File

@@ -19,6 +19,7 @@ type Args = {
export default async function SSRAutosavePage({ params: paramsPromise }: Args) { export default async function SSRAutosavePage({ params: paramsPromise }: Args) {
const { slug = '' } = await paramsPromise const { slug = '' } = await paramsPromise
const data = await getDoc<Page>({ const data = await getDoc<Page>({
slug, slug,
collection: ssrAutosavePagesSlug, collection: ssrAutosavePagesSlug,

View File

@@ -27,6 +27,7 @@ export const Pages: CollectionConfig = {
{ {
name: 'slug', name: 'slug',
type: 'text', type: 'text',
unique: true,
required: true, required: true,
admin: { admin: {
position: 'sidebar', position: 'sidebar',

View File

@@ -36,6 +36,7 @@ export const SSRAutosave: CollectionConfig = {
name: 'slug', name: 'slug',
type: 'text', type: 'text',
required: true, required: true,
unique: true,
admin: { admin: {
position: 'sidebar', position: 'sidebar',
}, },

View File

@@ -11,6 +11,11 @@ import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import { devUser } from '../credentials.js' import { devUser } from '../credentials.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js' import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import {
selectLivePreviewBreakpoint,
selectLivePreviewZoom,
toggleLivePreview,
} from '../helpers/e2e/live-preview/index.js'
import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js' import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js'
import { deletePreferences } from '../helpers/e2e/preferences.js' import { deletePreferences } from '../helpers/e2e/preferences.js'
import { waitForAutoSaveToRunAndComplete } from '../helpers/e2e/waitForAutoSaveToRunAndComplete.js' import { waitForAutoSaveToRunAndComplete } from '../helpers/e2e/waitForAutoSaveToRunAndComplete.js'
@@ -23,9 +28,6 @@ import {
goToCollectionLivePreview, goToCollectionLivePreview,
goToGlobalLivePreview, goToGlobalLivePreview,
goToTrashedLivePreview, goToTrashedLivePreview,
selectLivePreviewBreakpoint,
selectLivePreviewZoom,
toggleLivePreview,
} from './helpers.js' } from './helpers.js'
import { import {
collectionLevelConfigSlug, collectionLevelConfigSlug,
@@ -50,7 +52,7 @@ describe('Live Preview', () => {
let pagesURLUtil: AdminUrlUtil let pagesURLUtil: AdminUrlUtil
let postsURLUtil: AdminUrlUtil let postsURLUtil: AdminUrlUtil
let ssrPagesURLUtil: AdminUrlUtil let ssrPagesURLUtil: AdminUrlUtil
let ssrAutosavePostsURLUtil: AdminUrlUtil let ssrAutosavePagesURLUtil: AdminUrlUtil
let payload: PayloadTestSDK<Config> let payload: PayloadTestSDK<Config>
let user: any let user: any
@@ -61,7 +63,7 @@ describe('Live Preview', () => {
pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug) pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug)
postsURLUtil = new AdminUrlUtil(serverURL, postsSlug) postsURLUtil = new AdminUrlUtil(serverURL, postsSlug)
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug) ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
ssrAutosavePostsURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug) ssrAutosavePagesURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -105,7 +107,7 @@ describe('Live Preview', () => {
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden() await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
}) })
test('collection - does not enable live preview is collections that are not configured', async () => { test('collection - does not enable live preview in collections that are not configured', async () => {
const usersURL = new AdminUrlUtil(serverURL, 'users') const usersURL = new AdminUrlUtil(serverURL, 'users')
await navigateToDoc(page, usersURL) await navigateToDoc(page, usersURL)
const toggler = page.locator('#live-preview-toggler') const toggler = page.locator('#live-preview-toggler')
@@ -159,9 +161,10 @@ describe('Live Preview', () => {
await goToCollectionLivePreview(page, pagesURLUtil) await goToCollectionLivePreview(page, pagesURLUtil)
const iframe = page.locator('iframe.live-preview-iframe') const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible() await expect(iframe).toBeVisible()
await expect.poll(async () => iframe.getAttribute('src')).toMatch(/\/live-preview/)
}) })
test('collection csr — re-renders iframe client-side when form state changes', async () => { test('collection csr — iframe reflects form state on change', async () => {
await goToCollectionLivePreview(page, pagesURLUtil) await goToCollectionLivePreview(page, pagesURLUtil)
const titleField = page.locator('#field-title') const titleField = page.locator('#field-title')
@@ -242,7 +245,65 @@ describe('Live Preview', () => {
}) })
}) })
test('collection ssr — re-render iframe when save is made', async () => { test('collection csr — retains live preview connection after iframe src has changed', async () => {
const initialTitle = 'This is a test'
const testDoc = await payload.create({
collection: pagesSlug,
data: {
title: initialTitle,
slug: 'csr-test',
hero: {
type: 'none',
},
},
})
await page.goto(pagesURLUtil.edit(testDoc.id))
await toggleLivePreview(page, {
targetState: 'on',
})
const titleField = page.locator('#field-title')
const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible()
const pattern1 = new RegExp(`/live-preview/${testDoc.slug}`)
await expect.poll(async () => iframe.getAttribute('src')).toMatch(pattern1)
const slugField = page.locator('#field-slug')
const newSlug = `${testDoc.slug}-2`
await slugField.fill(newSlug)
await saveDocAndAssert(page)
// expect the iframe to have a new src that reflects the updated slug
await expect(iframe).toBeVisible()
const pattern2 = new RegExp(`/live-preview/${newSlug}`)
await expect.poll(async () => iframe.getAttribute('src')).toMatch(pattern2)
const frame = page.frameLocator('iframe.live-preview-iframe').first()
const renderedPageTitleLocator = `#${renderedPageTitleID}`
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${initialTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// edit the title and check the iframe updates
const newTitle = `${initialTitle} (Edited)`
await titleField.fill(newTitle)
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${newTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('collection ssr — iframe reflects form state on save', async () => {
await goToCollectionLivePreview(page, ssrPagesURLUtil) await goToCollectionLivePreview(page, ssrPagesURLUtil)
const titleField = page.locator('#field-title') const titleField = page.locator('#field-title')
@@ -305,9 +366,7 @@ describe('Live Preview', () => {
targetState: 'off', targetState: 'off',
}) })
await toggleLivePreview(page, { await toggleLivePreview(page)
targetState: 'on',
})
// The iframe should still be showing the updated title // The iframe should still be showing the updated title
await expect(frame.locator(renderedPageTitleLocator)).toHaveText( await expect(frame.locator(renderedPageTitleLocator)).toHaveText(
@@ -326,8 +385,63 @@ describe('Live Preview', () => {
}) })
}) })
test('collection ssr — re-render iframe when autosave is made', async () => { test('collection ssr — retains live preview connection after iframe src has changed', async () => {
await goToCollectionLivePreview(page, ssrAutosavePostsURLUtil) const initialTitle = 'This is a test'
const testDoc = await payload.create({
collection: ssrAutosavePagesSlug,
data: {
title: initialTitle,
slug: 'ssr-test',
hero: {
type: 'none',
},
},
})
await page.goto(ssrAutosavePagesURLUtil.edit(testDoc.id))
await toggleLivePreview(page, {
targetState: 'on',
})
const titleField = page.locator('#field-title')
const iframe = page.locator('iframe.live-preview-iframe')
const slugField = page.locator('#field-slug')
const newSlug = `${testDoc.slug}-2`
await slugField.fill(newSlug)
await waitForAutoSaveToRunAndComplete(page)
// expect the iframe to have a new src that reflects the updated slug
await expect(iframe).toBeVisible()
const pattern = new RegExp(`/live-preview/${ssrAutosavePagesSlug}/${newSlug}`)
await expect.poll(async () => iframe.getAttribute('src')).toMatch(pattern)
const frame = page.frameLocator('iframe.live-preview-iframe').first()
const renderedPageTitleLocator = `#${renderedPageTitleID}`
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${initialTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// edit the title and check the iframe updates
const newTitle = `${initialTitle} (Edited)`
await titleField.fill(newTitle)
await waitForAutoSaveToRunAndComplete(page)
await expect(() =>
expect(frame.locator(renderedPageTitleLocator)).toHaveText(`For Testing: ${newTitle}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('collection ssr — iframe reflects form state on autosave', async () => {
await goToCollectionLivePreview(page, ssrAutosavePagesURLUtil)
const titleField = page.locator('#field-title') const titleField = page.locator('#field-title')
const frame = page.frameLocator('iframe.live-preview-iframe').first() const frame = page.frameLocator('iframe.live-preview-iframe').first()
@@ -407,8 +521,6 @@ describe('Live Preview', () => {
test('global — can edit fields', async () => { test('global — can edit fields', async () => {
await goToGlobalLivePreview(page, 'header', serverURL) await goToGlobalLivePreview(page, 'header', serverURL)
const field = page.locator('input#field-navItems__0__link__newTab') //field-navItems__0__link__newTab const field = page.locator('input#field-navItems__0__link__newTab') //field-navItems__0__link__newTab
await expect(field).toBeVisible()
await expect(field).toBeEnabled()
await field.check() await field.check()
await saveDocAndAssert(page) await saveDocAndAssert(page)
}) })

View File

@@ -2,37 +2,11 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import { exactText } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { toggleLivePreview } from '../helpers/e2e/live-preview/toggleLivePreview.js'
import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js' import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js'
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js' import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
export const toggleLivePreview = async (
page: Page,
options?: {
targetState?: 'off' | 'on'
},
): Promise<void> => {
const toggler = page.locator('#live-preview-toggler')
await expect(toggler).toBeVisible()
const isActive = await toggler.evaluate((el) =>
el.classList.contains('live-preview-toggler--active'),
)
if (isActive && (options?.targetState === 'off' || !options?.targetState)) {
await toggler.click()
await expect(toggler).not.toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
}
if (!isActive && (options?.targetState === 'on' || !options?.targetState)) {
await toggler.click()
await expect(toggler).toHaveClass(/live-preview-toggler--active/)
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
}
}
export const goToCollectionLivePreview = async ( export const goToCollectionLivePreview = async (
page: Page, page: Page,
urlUtil: AdminUrlUtil, urlUtil: AdminUrlUtil,
@@ -65,59 +39,6 @@ export const goToGlobalLivePreview = async (
}) })
} }
export const selectLivePreviewBreakpoint = async (page: Page, breakpointLabel: string) => {
const breakpointSelector = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button',
)
await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await breakpointSelector.first().click()
await page
.locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`)
.filter({ hasText: breakpointLabel })
.click()
await expect(breakpointSelector).toContainText(breakpointLabel)
const option = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected',
)
await expect(option).toHaveText(breakpointLabel)
}
export const selectLivePreviewZoom = async (page: Page, zoomLabel: string) => {
const zoomSelector = page.locator('.live-preview-toolbar-controls__zoom button.popup-button')
await expect(() => expect(zoomSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await zoomSelector.first().click()
const zoomOption = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button',
{
hasText: exactText(zoomLabel),
},
)
expect(zoomOption).toBeTruthy()
await zoomOption.click()
await expect(zoomSelector).toContainText(zoomLabel)
const option = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button--selected',
)
await expect(option).toHaveText(zoomLabel)
}
export const ensureDeviceIsCentered = async (page: Page) => { export const ensureDeviceIsCentered = async (page: Page) => {
const main = page.locator('.live-preview-window__main') const main = page.locator('.live-preview-window__main')
const iframe = page.locator('iframe.live-preview-iframe') const iframe = page.locator('iframe.live-preview-iframe')

View File

@@ -45,7 +45,11 @@ export default withBundleAnalyzer(
] ]
}, },
images: { images: {
domains: ['localhost'], remotePatterns: [
{
hostname: 'localhost',
},
],
}, },
webpack: (webpackConfig) => { webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = { webpackConfig.resolve.extensionAlias = {