Compare commits

...

6 Commits

Author SHA1 Message Date
Jacob Fletcher
1b5e2d65e8 add tests 2025-09-02 23:05:48 -04:00
Jacob Fletcher
e720a3d21f init locale and fix types 2025-08-28 18:30:43 -04:00
Jacob Fletcher
abea03e741 cleanup 2025-08-28 18:16:29 -04:00
Jacob Fletcher
8b8f44d19f send field schema on url change 2025-08-28 18:04:18 -04:00
Jacob Fletcher
d0b1cbe7fd only refresh on submit 2025-08-28 11:02:32 -04:00
Jacob Fletcher
9d0ff9bb7a scaffold server fn and url deps pattern 2025-08-28 00:29:09 -04:00
25 changed files with 538 additions and 310 deletions

View 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)
}
}

View File

@@ -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,

View File

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

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>
)
}

View File

@@ -285,6 +285,7 @@ export { Warning as WarningIcon } from '../../providers/ToastContainer/icons/War
// providers
export {
type GetLivePreviewURLArgs,
type RenderDocumentResult,
type RenderDocumentServerFunction,
ServerFunctionsProvider,

View File

@@ -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'

View File

@@ -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,
})

View File

@@ -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,
}}
>

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View 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')
}
}
}

View File

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

View File

@@ -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,
],
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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)
})

View File

@@ -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')