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:
@@ -72,6 +72,7 @@ export const initReq = async function ({
|
||||
cookies,
|
||||
headers,
|
||||
})
|
||||
|
||||
const i18n: I18nClient = await initI18n({
|
||||
config: config.i18n,
|
||||
context: 'client',
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
DocumentViewServerProps,
|
||||
DocumentViewServerPropsOnly,
|
||||
EditViewComponent,
|
||||
LivePreviewConfig,
|
||||
PayloadComponent,
|
||||
RenderDocumentVersionsProperties,
|
||||
} from 'payload'
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
LivePreviewProvider,
|
||||
} from '@payloadcms/ui'
|
||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||
import { handleLivePreview } from '@payloadcms/ui/rsc'
|
||||
import { isEditing as getIsEditing } from '@payloadcms/ui/shared'
|
||||
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
|
||||
import { notFound, redirect } from 'next/navigation.js'
|
||||
@@ -348,36 +348,14 @@ export const renderDocument = async ({
|
||||
viewType,
|
||||
}
|
||||
|
||||
const isLivePreviewEnabled = Boolean(
|
||||
config.admin?.livePreview?.collections?.includes(collectionSlug) ||
|
||||
config.admin?.livePreview?.globals?.includes(globalSlug) ||
|
||||
collectionConfig?.admin?.livePreview ||
|
||||
globalConfig?.admin?.livePreview,
|
||||
)
|
||||
|
||||
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
|
||||
: ''
|
||||
const { isLivePreviewEnabled, livePreviewConfig, livePreviewURL } = await handleLivePreview({
|
||||
collectionSlug,
|
||||
config,
|
||||
data: doc,
|
||||
globalSlug,
|
||||
operation,
|
||||
req,
|
||||
})
|
||||
|
||||
return {
|
||||
data: doc,
|
||||
@@ -412,6 +390,7 @@ export const renderDocument = async ({
|
||||
breakpoints={livePreviewConfig?.breakpoints}
|
||||
isLivePreviewEnabled={isLivePreviewEnabled && operation !== 'create'}
|
||||
isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'}
|
||||
typeofLivePreviewURL={typeof livePreviewConfig?.url as 'function' | 'string' | undefined}
|
||||
url={livePreviewURL}
|
||||
>
|
||||
{showHeader && !drawerSlug && (
|
||||
|
||||
@@ -135,6 +135,12 @@ export type BuildFormStateArgs = {
|
||||
*/
|
||||
renderAllFields?: boolean
|
||||
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
|
||||
schemaPath: string
|
||||
select?: SelectType
|
||||
|
||||
@@ -151,10 +151,14 @@ export type LivePreviewConfig = {
|
||||
width: number | string
|
||||
}[]
|
||||
/**
|
||||
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.
|
||||
The frontend application is responsible for receiving the message and updating the UI accordingly.
|
||||
Use the `useLivePreview` hook to get started in React applications.
|
||||
* 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.
|
||||
* The frontend application is responsible for receiving the message and updating the UI accordingly.
|
||||
* 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?:
|
||||
| ((args: {
|
||||
|
||||
@@ -233,9 +233,7 @@ export type GlobalConfig<TSlug extends GlobalSlug = any> = {
|
||||
export interface SanitizedGlobalConfig
|
||||
extends Omit<DeepRequired<GlobalConfig>, 'endpoints' | 'fields' | 'slug' | 'versions'> {
|
||||
endpoints: Endpoint[] | false
|
||||
|
||||
fields: Field[]
|
||||
|
||||
/**
|
||||
* Fields in the database schema structure
|
||||
* Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded
|
||||
|
||||
@@ -6,24 +6,17 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'live-preview-iframe'
|
||||
|
||||
type Props = {
|
||||
ref: React.RefObject<HTMLIFrameElement>
|
||||
setIframeHasLoaded: (value: boolean) => void
|
||||
url: string
|
||||
}
|
||||
|
||||
export const IFrame: React.FC<Props> = (props) => {
|
||||
const { ref, setIframeHasLoaded, url } = props
|
||||
|
||||
const { zoom } = useLivePreviewContext()
|
||||
export const IFrame: React.FC = () => {
|
||||
const { iframeRef, setLoadedURL, url, zoom } = useLivePreviewContext()
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className={baseClass}
|
||||
key={url}
|
||||
onLoad={() => {
|
||||
setIframeHasLoaded(true)
|
||||
setLoadedURL(url)
|
||||
}}
|
||||
ref={ref}
|
||||
ref={iframeRef}
|
||||
src={url}
|
||||
style={{
|
||||
transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined,
|
||||
|
||||
@@ -12,9 +12,9 @@ import { useLivePreviewContext } from '../../../providers/LivePreview/context.js
|
||||
import { useLocale } from '../../../providers/Locale/index.js'
|
||||
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
|
||||
import { DeviceContainer } from '../Device/index.js'
|
||||
import './index.scss'
|
||||
import { IFrame } from '../IFrame/index.js'
|
||||
import { LivePreviewToolbar } from '../Toolbar/index.js'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'live-preview-window'
|
||||
|
||||
@@ -22,12 +22,11 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
const {
|
||||
appIsReady,
|
||||
breakpoint,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
isLivePreviewing,
|
||||
loadedURL,
|
||||
popupRef,
|
||||
previewWindowType,
|
||||
setIframeHasLoaded,
|
||||
url,
|
||||
} = useLivePreviewContext()
|
||||
|
||||
@@ -38,10 +37,12 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
const [formState] = useAllFormFields()
|
||||
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
|
||||
// Or it could be a separate popup window
|
||||
// We need to transmit data to both accordingly
|
||||
/**
|
||||
* For client-side apps, send data through `window.postMessage`
|
||||
* The preview could either be an iframe embedded on the page
|
||||
* Or it could be a separate popup window
|
||||
* We need to transmit data to both accordingly
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isLivePreviewing || !appIsReady) {
|
||||
return
|
||||
@@ -79,21 +80,22 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
url,
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
iframeHasLoaded,
|
||||
id,
|
||||
previewWindowType,
|
||||
popupRef,
|
||||
appIsReady,
|
||||
iframeRef,
|
||||
setIframeHasLoaded,
|
||||
mostRecentUpdate,
|
||||
locale,
|
||||
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
|
||||
// i.e., save, save draft, autosave, etc. will fire `router.refresh()`
|
||||
/**
|
||||
* To support SSR, we transmit a `window.postMessage` event without a payload
|
||||
* This is because the event will ultimately trigger a server-side roundtrip
|
||||
* i.e., save, save draft, autosave, etc. will fire `router.refresh()`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isLivePreviewing || !appIsReady) {
|
||||
return
|
||||
@@ -114,30 +116,26 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
}
|
||||
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url, isLivePreviewing, appIsReady])
|
||||
|
||||
if (previewWindowType === 'iframe') {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
baseClass,
|
||||
isLivePreviewing && `${baseClass}--is-live-previewing`,
|
||||
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
<LivePreviewToolbar {...props} />
|
||||
<div className={`${baseClass}__main`}>
|
||||
<DeviceContainer>
|
||||
{url ? (
|
||||
<IFrame ref={iframeRef} setIframeHasLoaded={setIframeHasLoaded} url={url} />
|
||||
) : (
|
||||
<ShimmerEffect height="100%" />
|
||||
)}
|
||||
</DeviceContainer>
|
||||
</div>
|
||||
if (previewWindowType !== 'iframe') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
baseClass,
|
||||
isLivePreviewing && `${baseClass}--is-live-previewing`,
|
||||
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
<LivePreviewToolbar {...props} />
|
||||
<div className={`${baseClass}__main`}>
|
||||
<DeviceContainer>{url ? <IFrame /> : <ShimmerEffect height="100%" />}</DeviceContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export { CheckIcon } from '../../icons/Check/index.js'
|
||||
export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js'
|
||||
export { getColumns } from '../../utilities/getColumns.js'
|
||||
export { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js'
|
||||
export { handleLivePreview } from '../../utilities/handleLivePreview.js'
|
||||
export { renderFilters, renderTable } from '../../utilities/renderTable.js'
|
||||
export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js'
|
||||
export { upsertPreferences } from '../../utilities/upsertPreferences.js'
|
||||
|
||||
@@ -18,13 +18,13 @@ export type Preferences = {
|
||||
|
||||
export type FormOnSuccess<T = unknown, C = Record<string, unknown>> = (
|
||||
json: T,
|
||||
options?: {
|
||||
ctx?: {
|
||||
/**
|
||||
* Arbitrary context passed to the onSuccess callback.
|
||||
*/
|
||||
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
|
||||
},
|
||||
|
||||
@@ -12,12 +12,17 @@ export interface LivePreviewContextType {
|
||||
appIsReady: boolean
|
||||
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
|
||||
breakpoints: LivePreviewConfig['breakpoints']
|
||||
iframeHasLoaded: boolean
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>
|
||||
isLivePreviewEnabled: boolean
|
||||
isLivePreviewing: boolean
|
||||
isPopupOpen: 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: {
|
||||
height: number
|
||||
width: number
|
||||
@@ -28,12 +33,17 @@ export interface LivePreviewContextType {
|
||||
setAppIsReady: (appIsReady: boolean) => void
|
||||
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
|
||||
setHeight: (height: number) => void
|
||||
setIframeHasLoaded: (loaded: boolean) => void
|
||||
setIsLivePreviewing: (isLivePreviewing: boolean) => void
|
||||
setLoadedURL: (loadedURL: string) => void
|
||||
setMeasuredDeviceSize: (size: { height: number; width: number }) => void
|
||||
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
|
||||
setSize: Dispatch<SizeReducerAction>
|
||||
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
|
||||
setZoom: (zoom: number) => void
|
||||
size: {
|
||||
@@ -44,6 +54,11 @@ export interface LivePreviewContextType {
|
||||
x: 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
|
||||
zoom: number
|
||||
}
|
||||
@@ -52,7 +67,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
appIsReady: false,
|
||||
breakpoint: undefined,
|
||||
breakpoints: undefined,
|
||||
iframeHasLoaded: false,
|
||||
iframeRef: undefined,
|
||||
isLivePreviewEnabled: undefined,
|
||||
isLivePreviewing: false,
|
||||
@@ -67,12 +81,13 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
setAppIsReady: () => {},
|
||||
setBreakpoint: () => {},
|
||||
setHeight: () => {},
|
||||
setIframeHasLoaded: () => {},
|
||||
setIsLivePreviewing: () => {},
|
||||
setLoadedURL: () => {},
|
||||
setMeasuredDeviceSize: () => {},
|
||||
setPreviewWindowType: () => {},
|
||||
setSize: () => {},
|
||||
setToolbarPosition: () => {},
|
||||
setURL: () => {},
|
||||
setWidth: () => {},
|
||||
setZoom: () => {},
|
||||
size: {
|
||||
@@ -83,6 +98,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
typeofLivePreviewURL: undefined,
|
||||
url: undefined,
|
||||
zoom: 1,
|
||||
})
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { CollectionPreferences, LivePreviewConfig } from 'payload'
|
||||
import { DndContext } from '@dnd-kit/core'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { LivePreviewContextType } from './context.js'
|
||||
|
||||
import { usePopupWindow } from '../../hooks/usePopupWindow.js'
|
||||
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
||||
import { usePreferences } from '../../providers/Preferences/index.js'
|
||||
import { formatAbsoluteURL } from '../../utilities/formatAbsoluteURL.js'
|
||||
import { customCollisionDetection } from './collisionDetection.js'
|
||||
import { LivePreviewContext } from './context.js'
|
||||
import { sizeReducer } from './sizeReducer.js'
|
||||
@@ -21,23 +24,15 @@ export type LivePreviewProviderProps = {
|
||||
}
|
||||
isLivePreviewEnabled?: boolean
|
||||
isLivePreviewing: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
const getAbsoluteUrl = (url) => {
|
||||
try {
|
||||
return new URL(url, window.location.origin).href
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
} & Pick<LivePreviewContextType, 'typeofLivePreviewURL' | 'url'>
|
||||
|
||||
export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
breakpoints: incomingBreakpoints,
|
||||
children,
|
||||
isLivePreviewEnabled,
|
||||
isLivePreviewing: incomingIsLivePreviewing,
|
||||
url: incomingUrl,
|
||||
typeofLivePreviewURL,
|
||||
url: urlFromProps,
|
||||
}) => {
|
||||
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
|
||||
const [isLivePreviewing, setIsLivePreviewing] = useState(incomingIsLivePreviewing)
|
||||
@@ -57,19 +52,6 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
|
||||
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({
|
||||
eventType: 'payload-live-preview',
|
||||
url,
|
||||
@@ -86,7 +68,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
|
||||
const [loadedURL, setLoadedURL] = useState<string>()
|
||||
|
||||
const [zoom, setZoom] = useState(1)
|
||||
|
||||
@@ -102,6 +84,31 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
const [breakpoint, setBreakpoint] =
|
||||
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
|
||||
const handleDragEnd = (ev) => {
|
||||
// only update position if the toolbar is completely within the preview area
|
||||
@@ -155,10 +162,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
}
|
||||
}, [breakpoint, breakpoints])
|
||||
|
||||
// Receive the `ready` message from the popup window
|
||||
// This indicates that the app is ready to receive `window.postMessage` events
|
||||
// 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
|
||||
/**
|
||||
* Receive the `ready` message from the popup window
|
||||
* This indicates that the app is ready to receive `window.postMessage` events
|
||||
* 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(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (
|
||||
@@ -224,12 +233,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
appIsReady,
|
||||
breakpoint,
|
||||
breakpoints,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
isLivePreviewEnabled,
|
||||
isLivePreviewing,
|
||||
isPopupOpen,
|
||||
listeningForMessages,
|
||||
loadedURL,
|
||||
measuredDeviceSize,
|
||||
openPopupWindow,
|
||||
popupRef,
|
||||
@@ -237,16 +246,18 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
setAppIsReady,
|
||||
setBreakpoint,
|
||||
setHeight,
|
||||
setIframeHasLoaded,
|
||||
setIsLivePreviewing,
|
||||
setLoadedURL,
|
||||
setMeasuredDeviceSize,
|
||||
setPreviewWindowType: handleWindowChange,
|
||||
setSize,
|
||||
setToolbarPosition: setPosition,
|
||||
setURL: setLivePreviewURL,
|
||||
setWidth,
|
||||
setZoom,
|
||||
size,
|
||||
toolbarPosition: position,
|
||||
typeofLivePreviewURL,
|
||||
url,
|
||||
zoom,
|
||||
}}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getClientConfig } from './getClientConfig.js'
|
||||
import { getClientSchemaMap } from './getClientSchemaMap.js'
|
||||
import { getSchemaMap } from './getSchemaMap.js'
|
||||
import { handleFormStateLocking } from './handleFormStateLocking.js'
|
||||
import { handleLivePreview } from './handleLivePreview.js'
|
||||
|
||||
export type LockedState = {
|
||||
isLocked: boolean
|
||||
@@ -27,11 +28,13 @@ type BuildFormStateSuccessResult = {
|
||||
clientConfig?: ClientConfig
|
||||
errors?: never
|
||||
indexPath?: string
|
||||
livePreviewURL?: string
|
||||
lockedState?: LockedState
|
||||
state: FormState
|
||||
}
|
||||
|
||||
type BuildFormStateErrorResult = {
|
||||
livePreviewURL?: never
|
||||
lockedState?: never
|
||||
state?: never
|
||||
} & (
|
||||
@@ -95,6 +98,7 @@ export const buildFormState = async (
|
||||
payload,
|
||||
payload: { config },
|
||||
},
|
||||
returnLivePreviewURL,
|
||||
returnLockStatus,
|
||||
schemaPath = collectionSlug || globalSlug,
|
||||
select,
|
||||
@@ -229,8 +233,26 @@ export const buildFormState = async (
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
const res: BuildFormStateSuccessResult = {
|
||||
lockedState: lockedStateResult,
|
||||
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
|
||||
}
|
||||
|
||||
18
packages/ui/src/utilities/formatAbsoluteURL.ts
Normal file
18
packages/ui/src/utilities/formatAbsoluteURL.ts
Normal 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)
|
||||
137
packages/ui/src/utilities/handleLivePreview.ts
Normal file
137
packages/ui/src/utilities/handleLivePreview.ts
Normal 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 }
|
||||
}
|
||||
@@ -134,7 +134,13 @@ export function DefaultEditView({
|
||||
const { resetUploadEdits } = useUploadEdits()
|
||||
const { getFormState } = useServerFunctions()
|
||||
const { startRouteTransition } = useRouteTransition()
|
||||
const { isLivePreviewEnabled, isLivePreviewing, previewWindowType } = useLivePreviewContext()
|
||||
const {
|
||||
isLivePreviewEnabled,
|
||||
isLivePreviewing,
|
||||
previewWindowType,
|
||||
setURL: setLivePreviewURL,
|
||||
typeofLivePreviewURL,
|
||||
} = useLivePreviewContext()
|
||||
|
||||
const abortOnChangeRef = useRef<AbortController>(null)
|
||||
const abortOnSaveRef = useRef<AbortController>(null)
|
||||
@@ -263,8 +269,8 @@ export function DefaultEditView({
|
||||
])
|
||||
|
||||
const onSave: FormOnSuccess<any, OnSaveContext> = useCallback(
|
||||
async (json, options) => {
|
||||
const { context, formState } = options || {}
|
||||
async (json, ctx) => {
|
||||
const { context, formState } = ctx || {}
|
||||
|
||||
const controller = handleAbortRef(abortOnSaveRef)
|
||||
|
||||
@@ -272,12 +278,6 @@ export function DefaultEditView({
|
||||
|
||||
const updatedAt = document?.updatedAt || new Date().toISOString()
|
||||
|
||||
reportUpdate({
|
||||
id,
|
||||
entitySlug,
|
||||
updatedAt,
|
||||
})
|
||||
|
||||
// If we're editing the doc of the logged-in user,
|
||||
// Refresh the cookie to get new permissions
|
||||
if (user && collectionSlug === userSlug && id === user.id) {
|
||||
@@ -328,7 +328,7 @@ export function DefaultEditView({
|
||||
if (id || globalSlug) {
|
||||
const docPreferences = await getDocPreferences()
|
||||
|
||||
const { state } = await getFormState({
|
||||
const { livePreviewURL, state } = await getFormState({
|
||||
id,
|
||||
collectionSlug,
|
||||
data: document,
|
||||
@@ -338,6 +338,7 @@ export function DefaultEditView({
|
||||
globalSlug,
|
||||
operation,
|
||||
renderAllFields: false,
|
||||
returnLivePreviewURL: isLivePreviewEnabled && typeofLivePreviewURL === 'function',
|
||||
returnLockStatus: false,
|
||||
schemaPath: schemaPathSegments.join('.'),
|
||||
signal: controller.signal,
|
||||
@@ -349,6 +350,16 @@ export function DefaultEditView({
|
||||
setDocumentIsLocked(false)
|
||||
}
|
||||
|
||||
if (isLivePreviewEnabled) {
|
||||
setLivePreviewURL(livePreviewURL)
|
||||
}
|
||||
|
||||
reportUpdate({
|
||||
id,
|
||||
entitySlug,
|
||||
updatedAt,
|
||||
})
|
||||
|
||||
abortOnSaveRef.current = null
|
||||
|
||||
return state
|
||||
@@ -367,7 +378,7 @@ export function DefaultEditView({
|
||||
isEditing,
|
||||
depth,
|
||||
redirectAfterCreate,
|
||||
getDocPermissions,
|
||||
setLivePreviewURL,
|
||||
globalSlug,
|
||||
refreshCookieAsync,
|
||||
incrementVersionCount,
|
||||
@@ -376,10 +387,13 @@ export function DefaultEditView({
|
||||
startRouteTransition,
|
||||
router,
|
||||
resetUploadEdits,
|
||||
getDocPermissions,
|
||||
getDocPreferences,
|
||||
getFormState,
|
||||
docPermissions,
|
||||
operation,
|
||||
isLivePreviewEnabled,
|
||||
typeofLivePreviewURL,
|
||||
schemaPathSegments,
|
||||
isLockingEnabled,
|
||||
setDocumentIsLocked,
|
||||
@@ -432,17 +446,17 @@ export function DefaultEditView({
|
||||
return state
|
||||
},
|
||||
[
|
||||
id,
|
||||
collectionSlug,
|
||||
editSessionStartTime,
|
||||
isLockingEnabled,
|
||||
getDocPreferences,
|
||||
getFormState,
|
||||
id,
|
||||
collectionSlug,
|
||||
docPermissions,
|
||||
globalSlug,
|
||||
handleDocumentLocking,
|
||||
isLockingEnabled,
|
||||
operation,
|
||||
schemaPathSegments,
|
||||
docPermissions,
|
||||
editSessionStartTime,
|
||||
handleDocumentLocking,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ export default withBundleAnalyzer(
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
domains: ['localhost'],
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
},
|
||||
env: {
|
||||
PAYLOAD_CORE_DEV: 'true',
|
||||
|
||||
3
test/helpers/e2e/live-preview/index.ts
Normal file
3
test/helpers/e2e/live-preview/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { selectLivePreviewBreakpoint } from './selectLivePreviewBreakpoint.js'
|
||||
export { selectLivePreviewZoom } from './selectLivePreviewZoom.js'
|
||||
export { toggleLivePreview } from './toggleLivePreview.js'
|
||||
29
test/helpers/e2e/live-preview/selectLivePreviewBreakpoint.ts
Normal file
29
test/helpers/e2e/live-preview/selectLivePreviewBreakpoint.ts
Normal 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)
|
||||
}
|
||||
33
test/helpers/e2e/live-preview/selectLivePreviewZoom.ts
Normal file
33
test/helpers/e2e/live-preview/selectLivePreviewZoom.ts
Normal 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)
|
||||
}
|
||||
29
test/helpers/e2e/live-preview/toggleLivePreview.ts
Normal file
29
test/helpers/e2e/live-preview/toggleLivePreview.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Args = {
|
||||
|
||||
export default async function SSRAutosavePage({ params: paramsPromise }: Args) {
|
||||
const { slug = '' } = await paramsPromise
|
||||
|
||||
const data = await getDoc<Page>({
|
||||
slug,
|
||||
collection: ssrAutosavePagesSlug,
|
||||
|
||||
@@ -27,6 +27,7 @@ export const Pages: CollectionConfig = {
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const SSRAutosave: CollectionConfig = {
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
||||
import { devUser } from '../credentials.js'
|
||||
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.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 { deletePreferences } from '../helpers/e2e/preferences.js'
|
||||
import { waitForAutoSaveToRunAndComplete } from '../helpers/e2e/waitForAutoSaveToRunAndComplete.js'
|
||||
@@ -23,9 +28,6 @@ import {
|
||||
goToCollectionLivePreview,
|
||||
goToGlobalLivePreview,
|
||||
goToTrashedLivePreview,
|
||||
selectLivePreviewBreakpoint,
|
||||
selectLivePreviewZoom,
|
||||
toggleLivePreview,
|
||||
} from './helpers.js'
|
||||
import {
|
||||
collectionLevelConfigSlug,
|
||||
@@ -50,7 +52,7 @@ describe('Live Preview', () => {
|
||||
let pagesURLUtil: AdminUrlUtil
|
||||
let postsURLUtil: AdminUrlUtil
|
||||
let ssrPagesURLUtil: AdminUrlUtil
|
||||
let ssrAutosavePostsURLUtil: AdminUrlUtil
|
||||
let ssrAutosavePagesURLUtil: AdminUrlUtil
|
||||
let payload: PayloadTestSDK<Config>
|
||||
let user: any
|
||||
|
||||
@@ -61,7 +63,7 @@ describe('Live Preview', () => {
|
||||
pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug)
|
||||
postsURLUtil = new AdminUrlUtil(serverURL, postsSlug)
|
||||
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
|
||||
ssrAutosavePostsURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
|
||||
ssrAutosavePagesURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
|
||||
|
||||
const context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
@@ -105,7 +107,7 @@ describe('Live Preview', () => {
|
||||
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')
|
||||
await navigateToDoc(page, usersURL)
|
||||
const toggler = page.locator('#live-preview-toggler')
|
||||
@@ -159,9 +161,10 @@ describe('Live Preview', () => {
|
||||
await goToCollectionLivePreview(page, pagesURLUtil)
|
||||
const iframe = page.locator('iframe.live-preview-iframe')
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
@@ -305,9 +366,7 @@ describe('Live Preview', () => {
|
||||
targetState: 'off',
|
||||
})
|
||||
|
||||
await toggleLivePreview(page, {
|
||||
targetState: 'on',
|
||||
})
|
||||
await toggleLivePreview(page)
|
||||
|
||||
// The iframe should still be showing the updated title
|
||||
await expect(frame.locator(renderedPageTitleLocator)).toHaveText(
|
||||
@@ -326,8 +385,63 @@ describe('Live Preview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('collection ssr — re-render iframe when autosave is made', async () => {
|
||||
await goToCollectionLivePreview(page, ssrAutosavePostsURLUtil)
|
||||
test('collection ssr — retains live preview connection after iframe src has changed', async () => {
|
||||
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 frame = page.frameLocator('iframe.live-preview-iframe').first()
|
||||
@@ -407,8 +521,6 @@ describe('Live Preview', () => {
|
||||
test('global — can edit fields', async () => {
|
||||
await goToGlobalLivePreview(page, 'header', serverURL)
|
||||
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 saveDocAndAssert(page)
|
||||
})
|
||||
|
||||
@@ -2,37 +2,11 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { exactText } from '../helpers.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 { 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 (
|
||||
page: Page,
|
||||
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) => {
|
||||
const main = page.locator('.live-preview-window__main')
|
||||
const iframe = page.locator('iframe.live-preview-iframe')
|
||||
|
||||
@@ -45,7 +45,11 @@ export default withBundleAnalyzer(
|
||||
]
|
||||
},
|
||||
images: {
|
||||
domains: ['localhost'],
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
},
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
|
||||
Reference in New Issue
Block a user