Compare commits
6 Commits
main
...
feat/live-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5e2d65e8 | ||
|
|
e720a3d21f | ||
|
|
abea03e741 | ||
|
|
8b8f44d19f | ||
|
|
d0b1cbe7fd | ||
|
|
9d0ff9bb7a |
60
packages/next/src/utilities/getLivePreviewURL.ts
Normal file
60
packages/next/src/utilities/getLivePreviewURL.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { GetLivePreviewURLArgs } from '@payloadcms/ui'
|
||||
import type { ErrorResult, ServerFunction } from 'payload'
|
||||
|
||||
import { canAccessAdmin } from '@payloadcms/ui/shared'
|
||||
import { formatErrors } from 'payload'
|
||||
|
||||
import { getRequestLocale } from './getRequestLocale.js'
|
||||
|
||||
export const getLivePreviewURLHandler: ServerFunction<
|
||||
GetLivePreviewURLArgs,
|
||||
Promise<
|
||||
| {
|
||||
url: null | string
|
||||
}
|
||||
| ErrorResult
|
||||
>
|
||||
> = async (args) => {
|
||||
const { collectionSlug, data, globalSlug, req } = args
|
||||
|
||||
await canAccessAdmin({ req })
|
||||
|
||||
try {
|
||||
const url = req.payload.config.admin.livePreview?.url
|
||||
|
||||
if (!url) {
|
||||
return { url: null }
|
||||
}
|
||||
|
||||
if (typeof url === 'string') {
|
||||
return { url }
|
||||
}
|
||||
|
||||
if (typeof url === 'function') {
|
||||
const locale = await getRequestLocale({
|
||||
req,
|
||||
})
|
||||
|
||||
const result = await url({
|
||||
collectionConfig: req.payload.config.collections.find(
|
||||
(coll) => coll.slug === collectionSlug,
|
||||
),
|
||||
data,
|
||||
globalConfig: globalSlug ? req.payload.globals[globalSlug] : undefined,
|
||||
locale,
|
||||
payload: req.payload,
|
||||
req,
|
||||
})
|
||||
|
||||
return { url: result }
|
||||
}
|
||||
} catch (err) {
|
||||
req.payload.logger.error({ err, msg: `There was an error getting the live preview URL` })
|
||||
|
||||
if (err.message === 'Unauthorized') {
|
||||
return null
|
||||
}
|
||||
|
||||
return formatErrors(err)
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublish
|
||||
import { renderDocumentHandler } from '../views/Document/handleServerFunction.js'
|
||||
import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js'
|
||||
import { renderListHandler } from '../views/List/handleServerFunction.js'
|
||||
import { getLivePreviewURLHandler } from './getLivePreviewURL.js'
|
||||
import { initReq } from './initReq.js'
|
||||
|
||||
const serverFunctions: Record<string, ServerFunction> = {
|
||||
'copy-data-from-locale': copyDataFromLocaleHandler,
|
||||
'form-state': buildFormStateHandler,
|
||||
'get-folder-results-component-and-data': getFolderResultsComponentAndDataHandler,
|
||||
'get-live-preview-url': getLivePreviewURLHandler,
|
||||
'render-document': renderDocumentHandler,
|
||||
'render-document-slots': renderDocumentSlotsHandler,
|
||||
'render-list': renderListHandler,
|
||||
|
||||
@@ -72,6 +72,7 @@ export const initReq = async function ({
|
||||
cookies,
|
||||
headers,
|
||||
})
|
||||
|
||||
const i18n: I18nClient = await initI18n({
|
||||
config: config.i18n,
|
||||
context: 'client',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RenderDocumentServerFunction } from '@payloadcms/ui'
|
||||
import type { DocumentPreferences, VisibleEntities } from 'payload'
|
||||
|
||||
import { canAccessAdmin } from '@payloadcms/ui/shared'
|
||||
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
|
||||
import { headers as getHeaders } from 'next/headers.js'
|
||||
import { getAccessResults, isEntityHidden, parseCookies } from 'payload'
|
||||
@@ -35,38 +36,7 @@ export const renderDocumentHandler: RenderDocumentServerFunction = async (args)
|
||||
|
||||
const cookies = parseCookies(headers)
|
||||
|
||||
const incomingUserSlug = user?.collection
|
||||
|
||||
const adminUserSlug = config.admin.user
|
||||
|
||||
// If we have a user slug, test it against the functions
|
||||
if (incomingUserSlug) {
|
||||
const adminAccessFunction = payload.collections[incomingUserSlug].config.access?.admin
|
||||
|
||||
// Run the admin access function from the config if it exists
|
||||
if (adminAccessFunction) {
|
||||
const canAccessAdmin = await adminAccessFunction({ req })
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
// Match the user collection to the global admin config
|
||||
} else if (adminUserSlug !== incomingUserSlug) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
} else {
|
||||
const hasUsers = await payload.find({
|
||||
collection: adminUserSlug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
// If there are users, we should not allow access because of /create-first-user
|
||||
if (hasUsers.docs.length) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
}
|
||||
await canAccessAdmin({ req })
|
||||
|
||||
const clientConfig = getClientConfig({
|
||||
config,
|
||||
|
||||
@@ -413,6 +413,7 @@ export const renderDocument = async ({
|
||||
isLivePreviewEnabled={isLivePreviewEnabled && operation !== 'create'}
|
||||
isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'}
|
||||
url={livePreviewURL}
|
||||
urlIsFunction={typeof livePreviewConfig?.url === 'function'}
|
||||
>
|
||||
{showHeader && !drawerSlug && (
|
||||
<DocumentHeader
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CollectionPreferences, ListQuery, ServerFunction, VisibleEntities } from 'payload'
|
||||
|
||||
import { canAccessAdmin } from '@payloadcms/ui/shared'
|
||||
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
|
||||
import { headers as getHeaders } from 'next/headers.js'
|
||||
import { getAccessResults, isEntityHidden, parseCookies } from 'payload'
|
||||
@@ -53,38 +54,7 @@ export const renderListHandler: ServerFunction<
|
||||
|
||||
const cookies = parseCookies(headers)
|
||||
|
||||
const incomingUserSlug = user?.collection
|
||||
|
||||
const adminUserSlug = config.admin.user
|
||||
|
||||
// If we have a user slug, test it against the functions
|
||||
if (incomingUserSlug) {
|
||||
const adminAccessFunction = payload.collections[incomingUserSlug].config.access?.admin
|
||||
|
||||
// Run the admin access function from the config if it exists
|
||||
if (adminAccessFunction) {
|
||||
const canAccessAdmin = await adminAccessFunction({ req })
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
// Match the user collection to the global admin config
|
||||
} else if (adminUserSlug !== incomingUserSlug) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
} else {
|
||||
const hasUsers = await payload.find({
|
||||
collection: adminUserSlug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
// If there are users, we should not allow access because of /create-first-user
|
||||
if (hasUsers.docs.length) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
}
|
||||
await canAccessAdmin({ req })
|
||||
|
||||
const clientConfig = getClientConfig({
|
||||
config,
|
||||
|
||||
@@ -6,24 +6,16 @@ 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}
|
||||
onLoad={() => {
|
||||
setIframeHasLoaded(true)
|
||||
setLoadedURL(url)
|
||||
}}
|
||||
ref={ref}
|
||||
ref={iframeRef}
|
||||
src={url}
|
||||
style={{
|
||||
transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined,
|
||||
|
||||
@@ -22,12 +22,12 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
appIsReady,
|
||||
breakpoint,
|
||||
fieldSchemaJSON,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
isLivePreviewing,
|
||||
loadedURL,
|
||||
popupRef,
|
||||
previewWindowType,
|
||||
setIframeHasLoaded,
|
||||
setLoadedURL,
|
||||
url,
|
||||
} = useLivePreviewContext()
|
||||
|
||||
@@ -38,12 +38,18 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
const prevWindowType =
|
||||
React.useRef<ReturnType<typeof useLivePreviewContext>['previewWindowType']>(undefined)
|
||||
|
||||
const prevLoadedURL = React.useRef<string | undefined>(loadedURL)
|
||||
|
||||
const [formState] = useAllFormFields()
|
||||
|
||||
// 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
|
||||
const loadedURLHasChanged = React.useRef(false)
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return
|
||||
@@ -53,14 +59,29 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
if (formState && window && 'postMessage' in window && appIsReady) {
|
||||
const values = reduceFieldsToValues(formState, true)
|
||||
|
||||
// To reduce on large `postMessage` payloads, only send `fieldSchemaToJSON` one time
|
||||
// To do this, the underlying JS function maintains a cache of this value
|
||||
// So we need to send it through each time the window type changes
|
||||
// But only once per window type change, not on every render, because this is a potentially large obj
|
||||
/**
|
||||
* To reduce on large `postMessage` payloads, only send `fieldSchemaToJSON` one time
|
||||
* To do this, the underlying JS function maintains a cache of this value
|
||||
* So we need to send it through each time the window type changes
|
||||
* But only once per window type change, not on every render, because this is a potentially large obj
|
||||
*/
|
||||
const shouldSendSchema =
|
||||
!prevWindowType.current || prevWindowType.current !== previewWindowType
|
||||
!prevWindowType.current ||
|
||||
prevWindowType.current !== previewWindowType ||
|
||||
loadedURLHasChanged
|
||||
|
||||
/**
|
||||
* Send the `fieldSchemaToJSON` again if the `url` attribute has changed
|
||||
* It must happen on the message cycle directly after the new URL has fully loaded
|
||||
*/
|
||||
if (prevLoadedURL.current !== loadedURL) {
|
||||
loadedURLHasChanged.current = true
|
||||
} else {
|
||||
loadedURLHasChanged.current = false
|
||||
}
|
||||
|
||||
prevWindowType.current = previewWindowType
|
||||
prevLoadedURL.current = loadedURL
|
||||
|
||||
const message = {
|
||||
type: 'payload-live-preview',
|
||||
@@ -83,21 +104,23 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
}, [
|
||||
formState,
|
||||
url,
|
||||
iframeHasLoaded,
|
||||
previewWindowType,
|
||||
popupRef,
|
||||
appIsReady,
|
||||
iframeRef,
|
||||
setIframeHasLoaded,
|
||||
setLoadedURL,
|
||||
fieldSchemaJSON,
|
||||
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) {
|
||||
return
|
||||
@@ -118,30 +141,29 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
}
|
||||
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url, isLivePreviewing])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// AFTER the url changes, we need to send the JSON schema again
|
||||
// we cannot simply do this in an effect like above, because it needs to happen AFTER the new app loads
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ export { Warning as WarningIcon } from '../../providers/ToastContainer/icons/War
|
||||
|
||||
// providers
|
||||
export {
|
||||
type GetLivePreviewURLArgs,
|
||||
type RenderDocumentResult,
|
||||
type RenderDocumentServerFunction,
|
||||
ServerFunctionsProvider,
|
||||
|
||||
@@ -10,6 +10,7 @@ export { filterFields } from '../../providers/TableColumns/buildColumnState/filt
|
||||
export { getInitialColumns } from '../../providers/TableColumns/getInitialColumns.js'
|
||||
export { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
|
||||
export { requests } from '../../utilities/api.js'
|
||||
export { canAccessAdmin } from '../../utilities/canAccessAdmin.js'
|
||||
export { findLocaleFromCode } from '../../utilities/findLocaleFromCode.js'
|
||||
export { formatAdminURL } from '../../utilities/formatAdminURL.js'
|
||||
export { formatDate } from '../../utilities/formatDocTitle/formatDateTitle.js'
|
||||
|
||||
@@ -14,12 +14,17 @@ export interface LivePreviewContextType {
|
||||
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
|
||||
breakpoints: LivePreviewConfig['breakpoints']
|
||||
fieldSchemaJSON?: ReturnType<typeof fieldSchemaToJSON>
|
||||
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
|
||||
@@ -30,12 +35,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: {
|
||||
@@ -47,6 +57,12 @@ export interface LivePreviewContextType {
|
||||
y: number
|
||||
}
|
||||
url: string | undefined
|
||||
/**
|
||||
* If true, indicates the that live preview URL config is defined as a function.
|
||||
* This tells the client that it needs to call the server to get the URL, rather than using a static string.
|
||||
* Useful to ensure that the server function is only called when necessary.
|
||||
*/
|
||||
urlIsFunction?: boolean
|
||||
zoom: number
|
||||
}
|
||||
|
||||
@@ -55,7 +71,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
breakpoint: undefined,
|
||||
breakpoints: undefined,
|
||||
fieldSchemaJSON: undefined,
|
||||
iframeHasLoaded: false,
|
||||
iframeRef: undefined,
|
||||
isLivePreviewEnabled: undefined,
|
||||
isLivePreviewing: false,
|
||||
@@ -70,12 +85,13 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
setAppIsReady: () => {},
|
||||
setBreakpoint: () => {},
|
||||
setHeight: () => {},
|
||||
setIframeHasLoaded: () => {},
|
||||
setIsLivePreviewing: () => {},
|
||||
setLoadedURL: () => {},
|
||||
setMeasuredDeviceSize: () => {},
|
||||
setPreviewWindowType: () => {},
|
||||
setSize: () => {},
|
||||
setToolbarPosition: () => {},
|
||||
setURL: () => {},
|
||||
setWidth: () => {},
|
||||
setZoom: () => {},
|
||||
size: {
|
||||
@@ -87,6 +103,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
y: 0,
|
||||
},
|
||||
url: undefined,
|
||||
urlIsFunction: false,
|
||||
zoom: 1,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import { DndContext } from '@dnd-kit/core'
|
||||
import { fieldSchemaToJSON } from 'payload/shared'
|
||||
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 { useConfig } from '../Config/index.js'
|
||||
import { customCollisionDetection } from './collisionDetection.js'
|
||||
import { LivePreviewContext } from './context.js'
|
||||
@@ -23,23 +26,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, 'url' | 'urlIsFunction'>
|
||||
|
||||
export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
breakpoints: incomingBreakpoints,
|
||||
children,
|
||||
isLivePreviewEnabled,
|
||||
isLivePreviewing: incomingIsLivePreviewing,
|
||||
url: incomingUrl,
|
||||
url: urlFromProps,
|
||||
urlIsFunction,
|
||||
}) => {
|
||||
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
|
||||
const [isLivePreviewing, setIsLivePreviewing] = useState(incomingIsLivePreviewing)
|
||||
@@ -59,18 +54,9 @@ 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],
|
||||
)
|
||||
useEffect(() => {
|
||||
setURL(formatAbsoluteURL(urlFromProps))
|
||||
}, [urlFromProps])
|
||||
|
||||
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
|
||||
eventType: 'payload-live-preview',
|
||||
@@ -88,7 +74,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
|
||||
const [loadedURL, setLoadedURL] = useState<string>()
|
||||
|
||||
const { config, getEntityConfig } = useConfig()
|
||||
|
||||
@@ -163,10 +149,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 (
|
||||
@@ -233,12 +221,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
breakpoint,
|
||||
breakpoints,
|
||||
fieldSchemaJSON,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
isLivePreviewEnabled,
|
||||
isLivePreviewing,
|
||||
isPopupOpen,
|
||||
listeningForMessages,
|
||||
loadedURL,
|
||||
measuredDeviceSize,
|
||||
openPopupWindow,
|
||||
popupRef,
|
||||
@@ -246,17 +234,19 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
setAppIsReady,
|
||||
setBreakpoint,
|
||||
setHeight,
|
||||
setIframeHasLoaded,
|
||||
setIsLivePreviewing,
|
||||
setLoadedURL,
|
||||
setMeasuredDeviceSize,
|
||||
setPreviewWindowType: handleWindowChange,
|
||||
setSize,
|
||||
setToolbarPosition: setPosition,
|
||||
setURL,
|
||||
setWidth,
|
||||
setZoom,
|
||||
size,
|
||||
toolbarPosition: position,
|
||||
url,
|
||||
urlIsFunction,
|
||||
zoom,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AdminViewServerPropsOnly,
|
||||
BuildFormStateArgs,
|
||||
BuildTableStateArgs,
|
||||
CollectionSlug,
|
||||
Data,
|
||||
DocumentPreferences,
|
||||
DocumentSlots,
|
||||
@@ -88,6 +89,14 @@ type CopyDataFromLocaleClient = (
|
||||
} & Omit<CopyDataFromLocaleArgs, 'req'>,
|
||||
) => Promise<{ data: Data }>
|
||||
|
||||
export type GetLivePreviewURLArgs = {
|
||||
collectionSlug: CollectionSlug
|
||||
data: Data
|
||||
globalSlug?: string
|
||||
}
|
||||
|
||||
type GetLivePreviewURLClient = (args: GetLivePreviewURLArgs) => Promise<{ url: null | string }>
|
||||
|
||||
type GetDocumentSlots = (args: {
|
||||
collectionSlug: string
|
||||
id?: number | string
|
||||
@@ -105,6 +114,11 @@ type ServerFunctionsContextType = {
|
||||
getDocumentSlots: GetDocumentSlots
|
||||
getFolderResultsComponentAndData: GetFolderResultsComponentAndDataClient
|
||||
getFormState: GetFormStateClient
|
||||
getLivePreviewURL: (args: {
|
||||
collectionSlug?: string
|
||||
data?: Data
|
||||
globalSlug?: string
|
||||
}) => Promise<{ url: null | string }>
|
||||
getTableState: GetTableStateClient
|
||||
renderDocument: RenderDocumentServerFunctionHookFn
|
||||
schedulePublish: SchedulePublishClient
|
||||
@@ -258,6 +272,23 @@ export const ServerFunctionsProvider: React.FC<{
|
||||
[serverFunction],
|
||||
)
|
||||
|
||||
const getLivePreviewURL = useCallback<GetLivePreviewURLClient>(
|
||||
async (args) => {
|
||||
try {
|
||||
const result = (await serverFunction({
|
||||
name: 'get-live-preview-url',
|
||||
args,
|
||||
})) as { url: null | string }
|
||||
|
||||
return result
|
||||
} catch (_err) {
|
||||
console.error(_err) // eslint-disable-line no-console
|
||||
}
|
||||
return { url: null }
|
||||
},
|
||||
[serverFunction],
|
||||
)
|
||||
|
||||
const getFolderResultsComponentAndData = useCallback<GetFolderResultsComponentAndDataClient>(
|
||||
async (args) => {
|
||||
const { signal: remoteSignal, ...rest } = args || {}
|
||||
@@ -285,6 +316,7 @@ export const ServerFunctionsProvider: React.FC<{
|
||||
getDocumentSlots,
|
||||
getFolderResultsComponentAndData,
|
||||
getFormState,
|
||||
getLivePreviewURL,
|
||||
getTableState,
|
||||
renderDocument,
|
||||
schedulePublish,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getSelectMode, reduceFieldsToValues } from 'payload/shared'
|
||||
|
||||
import { fieldSchemasToFormState } from '../forms/fieldSchemasToFormState/index.js'
|
||||
import { renderField } from '../forms/fieldSchemasToFormState/renderField.js'
|
||||
import { canAccessAdmin } from './canAccessAdmin.js'
|
||||
import { getClientConfig } from './getClientConfig.js'
|
||||
import { getClientSchemaMap } from './getClientSchemaMap.js'
|
||||
import { getSchemaMap } from './getSchemaMap.js'
|
||||
@@ -49,38 +50,9 @@ export const buildFormStateHandler: ServerFunction<
|
||||
> = async (args) => {
|
||||
const { req } = args
|
||||
|
||||
const incomingUserSlug = req.user?.collection
|
||||
const adminUserSlug = req.payload.config.admin.user
|
||||
|
||||
try {
|
||||
// If we have a user slug, test it against the functions
|
||||
if (incomingUserSlug) {
|
||||
const adminAccessFunction = req.payload.collections[incomingUserSlug].config.access?.admin
|
||||
|
||||
// Run the admin access function from the config if it exists
|
||||
if (adminAccessFunction) {
|
||||
const canAccessAdmin = await adminAccessFunction({ req })
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
// Match the user collection to the global admin config
|
||||
} else if (adminUserSlug !== incomingUserSlug) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
} else {
|
||||
const hasUsers = await req.payload.find({
|
||||
collection: adminUserSlug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
// If there are users, we should not allow access because of /create-first-user
|
||||
if (hasUsers.docs.length) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
}
|
||||
await canAccessAdmin({ req })
|
||||
|
||||
const res = await buildFormState(args)
|
||||
return res
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { APIError, formatErrors } from 'payload'
|
||||
import { isNumber } from 'payload/shared'
|
||||
|
||||
import { canAccessAdmin } from './canAccessAdmin.js'
|
||||
import { getClientConfig } from './getClientConfig.js'
|
||||
import { renderFilters, renderTable } from './renderTable.js'
|
||||
import { upsertPreferences } from './upsertPreferences.js'
|
||||
@@ -85,44 +86,11 @@ const buildTableState = async (
|
||||
i18n,
|
||||
payload,
|
||||
payload: { config },
|
||||
user,
|
||||
},
|
||||
tableAppearance,
|
||||
} = args
|
||||
|
||||
const incomingUserSlug = user?.collection
|
||||
|
||||
const adminUserSlug = config.admin.user
|
||||
|
||||
// If we have a user slug, test it against the functions
|
||||
if (incomingUserSlug) {
|
||||
const adminAccessFunction = payload.collections[incomingUserSlug].config.access?.admin
|
||||
|
||||
// Run the admin access function from the config if it exists
|
||||
if (adminAccessFunction) {
|
||||
const canAccessAdmin = await adminAccessFunction({ req })
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// Match the user collection to the global admin config
|
||||
} else if (adminUserSlug !== incomingUserSlug) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
} else {
|
||||
const hasUsers = await payload.find({
|
||||
collection: adminUserSlug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
// If there are users, we should not allow access because of /create-first-user
|
||||
if (hasUsers.docs.length) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
}
|
||||
await canAccessAdmin({ req })
|
||||
|
||||
const clientConfig = getClientConfig({
|
||||
config,
|
||||
|
||||
41
packages/ui/src/utilities/canAccessAdmin.ts
Normal file
41
packages/ui/src/utilities/canAccessAdmin.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
/**
|
||||
* Protects admin-only routes, server functions, etc.
|
||||
* The requesting user must either::
|
||||
* a. pass the `access.admin` function on the `users` collection, if defined
|
||||
* b. match the `config.admin.user` property on the Payload config
|
||||
* c. if no user is present, and there are no users in the system, allow access (for first user creation)
|
||||
* @throws {Error} Throws an `Unauthorized` error if access is denied that can be explicitly caught
|
||||
*/
|
||||
export const canAccessAdmin = async ({ req }: { req: PayloadRequest }) => {
|
||||
const incomingUserSlug = req.user?.collection
|
||||
const adminUserSlug = req.payload.config.admin.user
|
||||
|
||||
if (incomingUserSlug) {
|
||||
const adminAccessFn = req.payload.collections[incomingUserSlug].config.access?.admin
|
||||
|
||||
if (adminAccessFn) {
|
||||
const canAccess = await adminAccessFn({ req })
|
||||
|
||||
if (!canAccess) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
// Match the user collection to the global admin config
|
||||
} else if (adminUserSlug !== incomingUserSlug) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
} else {
|
||||
const hasUsers = await req.payload.find({
|
||||
collection: adminUserSlug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
// If there are users, we should not allow access because of `/create-first-user`
|
||||
if (hasUsers.docs.length) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -33,6 +33,7 @@ import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
|
||||
import { UploadControlsProvider } from '../../providers/UploadControls/index.js'
|
||||
import { useUploadEdits } from '../../providers/UploadEdits/index.js'
|
||||
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
|
||||
import { formatAbsoluteURL } from '../../utilities/formatAbsoluteURL.js'
|
||||
import { handleBackToDashboard } from '../../utilities/handleBackToDashboard.js'
|
||||
import { handleGoBack } from '../../utilities/handleGoBack.js'
|
||||
import { handleTakeOver } from '../../utilities/handleTakeOver.js'
|
||||
@@ -132,9 +133,16 @@ export function DefaultEditView({
|
||||
const params = useSearchParams()
|
||||
const { reportUpdate } = useDocumentEvents()
|
||||
const { resetUploadEdits } = useUploadEdits()
|
||||
const { getFormState } = useServerFunctions()
|
||||
const { getFormState, getLivePreviewURL } = useServerFunctions()
|
||||
const { startRouteTransition } = useRouteTransition()
|
||||
const { isLivePreviewEnabled, isLivePreviewing, previewWindowType } = useLivePreviewContext()
|
||||
const {
|
||||
isLivePreviewEnabled,
|
||||
isLivePreviewing,
|
||||
previewWindowType,
|
||||
setURL: setLivePreviewURL,
|
||||
url: livePreviewURL,
|
||||
urlIsFunction: shouldGetLivePreviewURL,
|
||||
} = useLivePreviewContext()
|
||||
|
||||
const abortOnChangeRef = useRef<AbortController>(null)
|
||||
const abortOnSaveRef = useRef<AbortController>(null)
|
||||
@@ -294,6 +302,23 @@ export function DefaultEditView({
|
||||
void setData(document || {})
|
||||
}
|
||||
|
||||
// Refresh live preview url, if needed
|
||||
// One potential optimization here would be to only do this if certain fields changed
|
||||
// And/or also combing this with some other action, like the submit itself
|
||||
if (isLivePreviewEnabled && shouldGetLivePreviewURL) {
|
||||
const { url: newURLRaw } = await getLivePreviewURL({
|
||||
collectionSlug,
|
||||
data: document,
|
||||
globalSlug,
|
||||
})
|
||||
|
||||
const newLivePreviewURL = formatAbsoluteURL(newURLRaw)
|
||||
|
||||
if (newLivePreviewURL && newLivePreviewURL !== livePreviewURL) {
|
||||
setLivePreviewURL(newLivePreviewURL)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof onSaveFromContext === 'function') {
|
||||
const operation = id ? 'update' : 'create'
|
||||
|
||||
@@ -383,6 +408,11 @@ export function DefaultEditView({
|
||||
schemaPathSegments,
|
||||
isLockingEnabled,
|
||||
setDocumentIsLocked,
|
||||
setLivePreviewURL,
|
||||
livePreviewURL,
|
||||
getLivePreviewURL,
|
||||
isLivePreviewEnabled,
|
||||
shouldGetLivePreviewURL,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export default buildConfigWithDefaults({
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
livePreview: {
|
||||
urlDeps: ['tenant', 'slug'],
|
||||
// You can define any of these properties on a per collection or global basis
|
||||
// The Live Preview config cascades from the top down, properties are inherited from here
|
||||
url: formatLivePreviewURL,
|
||||
|
||||
@@ -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()
|
||||
@@ -159,9 +161,30 @@ describe('Live Preview', () => {
|
||||
await goToCollectionLivePreview(page, pagesURLUtil)
|
||||
const iframe = page.locator('iframe.live-preview-iframe')
|
||||
await expect(iframe).toBeVisible()
|
||||
expect(iframe.getAttribute('src')).toContain('/live-preview')
|
||||
})
|
||||
|
||||
test('collection csr — re-renders iframe client-side when form state changes', async () => {
|
||||
test('collection — regenerates iframe src on save and retains live preview connection', async () => {
|
||||
await goToCollectionLivePreview(page, pagesURLUtil)
|
||||
|
||||
await toggleLivePreview(page)
|
||||
|
||||
// expect the iframe to load using the initial slug
|
||||
const iframe = page.locator('iframe.live-preview-iframe')
|
||||
await expect(iframe).toBeVisible()
|
||||
await expect(iframe).toHaveAttribute('src', /\/live-preview\/slug-1/)
|
||||
|
||||
// change the slug and save
|
||||
const slugField = page.locator('#field-slug')
|
||||
await slugField.fill('slug-1-updated')
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// expect the iframe to have a new `src` attribute that reflects the updated slug
|
||||
await expect(iframe).toBeVisible()
|
||||
await expect(iframe).toHaveAttribute('src', /\/live-preview\/slug-1-updated/)
|
||||
})
|
||||
|
||||
test('collection csr — iframe reflects form state on change', async () => {
|
||||
await goToCollectionLivePreview(page, pagesURLUtil)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
@@ -242,7 +265,59 @@ 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: 'pages',
|
||||
data: {
|
||||
title: initialTitle,
|
||||
slug: 'this-is-a-test',
|
||||
hero: {
|
||||
type: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(pagesURLUtil.edit(testDoc.id))
|
||||
|
||||
await toggleLivePreview(page)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
const iframe = page.locator('iframe.live-preview-iframe')
|
||||
|
||||
const slugField = page.locator('#field-slug')
|
||||
const newSlug = 'this-is-a-test-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 pattern = new RegExp(`/live-preview/${ssrAutosavePagesSlug}/${newSlug}`)
|
||||
await expect(iframe).toHaveAttribute('src', 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 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 +380,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 +399,61 @@ 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: 'this-is-a-test',
|
||||
hero: {
|
||||
type: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(ssrAutosavePagesURLUtil.edit(testDoc.id))
|
||||
|
||||
await toggleLivePreview(page)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
const iframe = page.locator('iframe.live-preview-iframe')
|
||||
|
||||
const slugField = page.locator('#field-slug')
|
||||
const newSlug = 'this-is-a-test-2'
|
||||
await slugField.pressSequentially(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(iframe).toHaveAttribute('src', 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 +533,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')
|
||||
|
||||
Reference in New Issue
Block a user