Compare commits

...

14 Commits

Author SHA1 Message Date
Elliot DeNolf
5ecfe3da28 chore(release): richtext-lexical/0.1.13 2023-10-17 13:18:30 -04:00
Elliot DeNolf
3e3163e875 chore(release): live-preview/0.1.4 2023-10-17 13:17:33 -04:00
Elliot DeNolf
cfe1698dfd chore: update changelog 2023-10-17 13:15:36 -04:00
Elliot DeNolf
5722634660 chore(release): v2.0.8 2023-10-17 13:06:25 -04:00
Jacob Fletcher
cdbfc9132a chore: optimizes live preview (#3713) 2023-10-17 12:42:31 -04:00
Jacob Fletcher
dd0ac066ce feat(live-preview): caches field schema (#3711) 2023-10-17 12:11:55 -04:00
PatrikKozak
8b8ceabbdd fix(templates): user access control (#3712) 2023-10-17 12:07:37 -04:00
Alessio Gravili
8da18d3496 Merge pull request #3707 from payloadcms/fix/3683
fix(richtext-lexical): Blocks Field: Sub-forms being re-rendered unnecessarily
2023-10-17 17:33:05 +02:00
Elliot DeNolf
6cd4df3dc4 chore(script): properly version and publish non-latest packages 2023-10-17 11:30:09 -04:00
Elliot DeNolf
e74dc8633b ci: add retries to e2e tests (#3708)
* ci: add retries to e2e tests

* ci: add timeout_minutes for retry
2023-10-17 11:26:34 -04:00
Alessio Gravili
2f945919a3 chore: disable props for useIntersect hook 2023-10-17 17:24:50 +02:00
Alessio Gravili
52dc9177d7 chore: enable forceRender for richtext-lexical's Blocks feature form, and all their sub-forms 2023-10-17 16:44:34 +02:00
Alessio Gravili
cec757d098 chore: make sure intersectionObserver does not cause re-renders if forceRender is enabled 2023-10-17 16:34:44 +02:00
Jacob Fletcher
80e57150a0 chore: refines live preview shadow and bg (#3706) 2023-10-17 10:11:21 -04:00
41 changed files with 447 additions and 297 deletions

View File

@@ -132,7 +132,12 @@ jobs:
key: ${{ github.sha }}-${{ github.run_number }}
- name: E2E Tests
run: pnpm test:e2e --part ${{ matrix.part }} --bail
uses: nick-fields/retry@v2
with:
retry_on: error
max_attempts: 2
timeout_minutes: 15
command: pnpm test:e2e --part ${{ matrix.part }} --bail
- uses: actions/upload-artifact@v3
if: always()

View File

@@ -1,3 +1,28 @@
## [2.0.8](https://github.com/payloadcms/payload/compare/v2.0.7...v2.0.8) (2023-10-17)
### Features
* allows filterOptions to return null ([c4cac99](https://github.com/payloadcms/payload/commit/c4cac998752730e7084598c92c77789da8c82e0d))
* **live-preview:** caches field schema ([#3711](https://github.com/payloadcms/payload/issues/3711)) ([dd0ac06](https://github.com/payloadcms/payload/commit/dd0ac066ce2ed88b85025309303610a95b6089e1))
### Bug Fixes
* autosave time shown minutes only ([#3492](https://github.com/payloadcms/payload/issues/3492)) ([e311e8f](https://github.com/payloadcms/payload/commit/e311e8fff9cd4264d7a71903f63c4fa825a3564d))
* blocks within groups in postgres ([45a62ba](https://github.com/payloadcms/payload/commit/45a62ba949aca33b25e0808773a5c2f1cf4adf82))
* bug with seeding ecommerce ([993568a](https://github.com/payloadcms/payload/commit/993568a1959ea10f960e35e4ed7a8e06af672a72))
* corrects add block index ([#3681](https://github.com/payloadcms/payload/issues/3681)) ([3c50443](https://github.com/payloadcms/payload/commit/3c5044368d5b30c76a2ff20c25b9234ef89dc205))
* misc upload crop/focal point updates ([#3580](https://github.com/payloadcms/payload/issues/3580)) ([d616772](https://github.com/payloadcms/payload/commit/d6167727401a01282345e63636560e029ae8e0f3))
* renders mobile document controls ([#3695](https://github.com/payloadcms/payload/issues/3695)) ([1625ff2](https://github.com/payloadcms/payload/commit/1625ff244e6e81e6edc0357037c3abc1a3bf8ba7))
* some local operations missing req.transactionID ([#3651](https://github.com/payloadcms/payload/issues/3651)) ([150799e](https://github.com/payloadcms/payload/commit/150799e10e580281d1af49388eb142ee9639a002))
* **richtext-*:** extra fields not being iterated correctly ([#3693](https://github.com/payloadcms/payload/issues/3693)) ([b8a5866](https://github.com/payloadcms/payload/commit/b8a58666e70f604af1e1cf349bcb4f9add0147e7))
* **richtext-*:** link drawer form receiving incorrect field schema ([#3696](https://github.com/payloadcms/payload/issues/3696)) ([cb39354](https://github.com/payloadcms/payload/commit/cb39354a9de3d20960110e453f62c4aa166d8448))
* **richtext-lexical:** [#3682](https://github.com/payloadcms/payload/issues/3682) isolated editor container causing z-index issues ([24918fe](https://github.com/payloadcms/payload/commit/24918fe1d2ca251e211632765d370c214cef2a38))
* **templates:** user access control ([#3712](https://github.com/payloadcms/payload/issues/3712)) ([8b8ceab](https://github.com/payloadcms/payload/commit/8b8ceabbdd6354761e7d744cacb1192cac3a2427))
## [2.0.6](https://github.com/payloadcms/payload/compare/v2.0.5...v2.0.6) (2023-10-14)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "0.1.3",
"version": "0.1.4",
"description": "The official live preview JavaScript SDK for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",

View File

@@ -1,5 +1,11 @@
import { mergeData } from '.'
// For performance reasons, `fieldSchemaJSON` will only be sent once on the initial message
// We need to cache this value so that it can be used across subsequent messages
// To do this, save `fieldSchemaJSON` when it arrives as a global variable
// Send this cached value to `mergeData`, instead of `eventData.fieldSchemaJSON` directly
let payloadLivePreviewFieldSchema = undefined // TODO: type this from `fieldSchemaToJSON` return type
export const handleMessage = async <T>(args: {
depth: number
event: MessageEvent
@@ -11,9 +17,13 @@ export const handleMessage = async <T>(args: {
const eventData = JSON.parse(event?.data)
if (eventData.type === 'payload-live-preview') {
if (!payloadLivePreviewFieldSchema && eventData.fieldSchemaJSON) {
payloadLivePreviewFieldSchema = eventData.fieldSchemaJSON
}
const mergedData = await mergeData<T>({
depth,
fieldSchema: eventData.fieldSchemaJSON,
fieldSchema: payloadLivePreviewFieldSchema,
incomingData: eventData.data,
initialData,
serverURL,

View File

@@ -2,11 +2,14 @@ export const ready = (args: { serverURL: string }): void => {
const { serverURL } = args
if (typeof window !== 'undefined') {
// This subscription may have been from either an iframe `src` or `window.open()`
// i.e. `window?.opener` || `window?.parent`
window?.opener?.postMessage(
// This subscription may have been from either an iframe or a popup
// We need to report 'ready' to the parent window, whichever it may be
// i.e. `window?.opener` for popups, `window?.parent` for iframes
const windowToPostTo: Window = window?.opener || window?.parent
windowToPostTo?.postMessage(
JSON.stringify({
popupReady: true,
ready: true,
type: 'payload-live-preview',
}),
serverURL,

View File

@@ -9,7 +9,7 @@ export const subscribe = <T>(args: {
const { callback, depth, initialData, serverURL } = args
const onMessage = async (event: MessageEvent) => {
const mergedData = await handleMessage({ depth, event, initialData, serverURL })
const mergedData = await handleMessage<T>({ depth, event, initialData, serverURL })
callback(mergedData)
}

View File

@@ -12,7 +12,7 @@ module.exports = {
skipChecks: true,
},
hooks: {
'before:init': ['pnpm install', 'pnpm clean', 'pnpm test'],
'before:init': ['pnpm install', 'pnpm clean', 'pnpm build'], // Assume tests have already been run
},
plugins: {
'@release-it/conventional-changelog': {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "2.0.7",
"version": "2.0.8",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"main": "./dist/index.js",

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Props } from './types'
@@ -37,7 +37,7 @@ const RenderFields: React.FC<Props> = (props) => {
const { i18n, t } = useTranslation('general')
const [hasRendered, setHasRendered] = useState(Boolean(forceRender))
const [intersectionRef, entry] = useIntersect(intersectionObserverOptions)
const [intersectionRef, entry] = useIntersect(intersectionObserverOptions, forceRender)
const isIntersecting = Boolean(entry?.isIntersecting)
const isAboveViewport = entry?.boundingClientRect?.top < 0
@@ -105,6 +105,7 @@ const RenderFields: React.FC<Props> = (props) => {
readOnly,
},
fieldTypes,
forceRender,
indexPath:
'indexPath' in props ? `${props?.indexPath}.${fieldIndex}` : `${fieldIndex}`,
path: field.path || (isFieldAffectingData && 'name' in field ? field.name : ''),

View File

@@ -24,6 +24,7 @@ type ArrayRowProps = UseDraggableSortableReturn &
CustomRowLabel?: RowLabelType
addRow: (rowIndex: number) => void
duplicateRow: (rowIndex: number) => void
forceRender?: boolean
hasMaxRows?: boolean
moveRow: (fromIndex: number, toIndex: number) => void
readOnly?: boolean
@@ -40,6 +41,7 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
duplicateRow,
fieldTypes,
fields,
forceRender = false,
hasMaxRows,
indexPath,
labels,
@@ -126,10 +128,11 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
path: createNestedFieldPath(path, field),
}))}
fieldTypes={fieldTypes}
forceRender={forceRender}
indexPath={indexPath}
margins="small"
permissions={permissions?.fields}
readOnly={readOnly}
margins="small"
/>
</Collapsible>
</div>

View File

@@ -32,6 +32,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
admin: { className, components, condition, description, readOnly },
fieldTypes,
fields,
forceRender = false,
indexPath,
localized,
maxRows,
@@ -234,6 +235,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
duplicateRow={duplicateRow}
fieldTypes={fieldTypes}
fields={fields}
forceRender={forceRender}
hasMaxRows={hasMaxRows}
indexPath={indexPath}
labels={labels}

View File

@@ -4,6 +4,7 @@ import type { ArrayField } from '../../../../../fields/config/types'
export type Props = Omit<ArrayField, 'type'> & {
fieldTypes: FieldTypes
forceRender?: boolean
indexPath: string
label: false | string
path?: string

View File

@@ -24,6 +24,7 @@ type BlockFieldProps = UseDraggableSortableReturn &
addRow: (rowIndex: number, blockType: string) => void
blockToRender: Block
duplicateRow: (rowIndex: number) => void
forceRender?: boolean
hasMaxRows?: boolean
moveRow: (fromIndex: number, toIndex: number) => void
readOnly: boolean
@@ -40,6 +41,7 @@ export const BlockRow: React.FC<BlockFieldProps> = ({
blocks,
duplicateRow,
fieldTypes,
forceRender,
hasMaxRows,
indexPath,
labels,
@@ -130,6 +132,7 @@ export const BlockRow: React.FC<BlockFieldProps> = ({
path: createNestedFieldPath(path, field),
}))}
fieldTypes={fieldTypes}
forceRender={forceRender}
indexPath={indexPath}
margins="small"
permissions={permissions?.blocks?.[row.blockType]?.fields}

View File

@@ -37,6 +37,7 @@ const BlocksField: React.FC<Props> = (props) => {
admin: { className, condition, description, readOnly },
blocks,
fieldTypes,
forceRender = false,
indexPath,
label,
labels: labelsFromProps,
@@ -238,6 +239,7 @@ const BlocksField: React.FC<Props> = (props) => {
blocks={blocks}
duplicateRow={duplicateRow}
fieldTypes={fieldTypes}
forceRender={forceRender}
hasMaxRows={hasMaxRows}
indexPath={indexPath}
labels={labels}

View File

@@ -4,6 +4,7 @@ import type { BlockField } from '../../../../../fields/config/types'
export type Props = Omit<BlockField, 'type'> & {
fieldTypes: FieldTypes
forceRender?: boolean
indexPath: string
path?: string
permissions: FieldPermissions

View File

@@ -14,9 +14,9 @@ import { WatchChildErrors } from '../../WatchChildErrors'
import withCondition from '../../withCondition'
import { useRow } from '../Row/provider'
import { useTabs } from '../Tabs/provider'
import { fieldBaseClass } from '../shared'
import './index.scss'
import { GroupProvider, useGroup } from './provider'
import { fieldBaseClass } from '../shared'
const baseClass = 'group-field'
@@ -26,6 +26,7 @@ const Group: React.FC<Props> = (props) => {
admin: { className, description, hideGutter = false, readOnly, style, width },
fieldTypes,
fields,
forceRender = false,
indexPath,
label,
path: pathFromProps,
@@ -88,6 +89,7 @@ const Group: React.FC<Props> = (props) => {
path: createNestedFieldPath(path, subField),
}))}
fieldTypes={fieldTypes}
forceRender={forceRender}
indexPath={indexPath}
margins="small"
permissions={permissions?.fields}

View File

@@ -4,6 +4,7 @@ import type { GroupField } from '../../../../../fields/config/types'
export type Props = Omit<GroupField, 'type'> & {
fieldTypes: FieldTypes
forceRender?: boolean
indexPath: string
path?: string
permissions: FieldPermissions

View File

@@ -14,6 +14,7 @@ const Row: React.FC<Props> = (props) => {
admin: { className, readOnly },
fieldTypes,
fields,
forceRender = false,
indexPath,
path,
permissions,
@@ -28,6 +29,7 @@ const Row: React.FC<Props> = (props) => {
path: createNestedFieldPath(path, field),
}))}
fieldTypes={fieldTypes}
forceRender={forceRender}
indexPath={indexPath}
permissions={permissions}
readOnly={readOnly}

View File

@@ -4,6 +4,7 @@ import type { RowField } from '../../../../../fields/config/types'
export type Props = Omit<RowField, 'type'> & {
fieldTypes: FieldTypes
forceRender?: boolean
indexPath: string
path?: string
permissions: FieldPermissions

View File

@@ -18,9 +18,9 @@ import { createNestedFieldPath } from '../../Form/createNestedFieldPath'
import RenderFields from '../../RenderFields'
import { WatchChildErrors } from '../../WatchChildErrors'
import withCondition from '../../withCondition'
import { fieldBaseClass } from '../shared'
import './index.scss'
import { TabsProvider } from './provider'
import { fieldBaseClass } from '../shared'
const baseClass = 'tabs-field'
@@ -72,6 +72,7 @@ const TabsField: React.FC<Props> = (props) => {
const {
admin: { className, readOnly },
fieldTypes,
forceRender = false,
indexPath,
path,
permissions,
@@ -188,7 +189,7 @@ const TabsField: React.FC<Props> = (props) => {
}
})}
fieldTypes={fieldTypes}
forceRender
forceRender={forceRender}
indexPath={indexPath}
key={String(activeTabConfig.label)}
margins="small"

View File

@@ -4,6 +4,7 @@ import type { TabsField } from '../../../../../fields/config/types'
export type Props = Omit<TabsField, 'type'> & {
fieldTypes: FieldTypes
forceRender?: boolean
indexPath: string
path?: string
permissions: FieldPermissions

View File

@@ -3,21 +3,31 @@ import type { Dispatch } from 'react'
import { createContext, useContext } from 'react'
import type { LivePreviewConfig } from '../../../../../exports/config'
import type { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON'
import type { usePopupWindow } from '../usePopupWindow'
import type { SizeReducerAction } from './sizeReducer'
export interface LivePreviewContextType {
appIsReady: boolean
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
breakpoints: LivePreviewConfig['breakpoints']
fieldSchemaJSON?: ReturnType<typeof fieldSchemaToJSON>
iframeHasLoaded: boolean
iframeRef: React.RefObject<HTMLIFrameElement>
isPopupOpen: boolean
measuredDeviceSize: {
height: number
width: number
}
openPopupWindow: ReturnType<typeof usePopupWindow>['openPopupWindow']
popupRef?: React.MutableRefObject<Window | null>
previewWindowType: 'iframe' | 'popup'
setAppIsReady: (appIsReady: boolean) => void
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
setHeight: (height: number) => void
setIframeHasLoaded: (loaded: boolean) => void
setMeasuredDeviceSize: (size: { height: number; width: number }) => void
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
setSize: Dispatch<SizeReducerAction>
setToolbarPosition: (position: { x: number; y: number }) => void
setWidth: (width: number) => void
@@ -30,22 +40,31 @@ export interface LivePreviewContextType {
x: number
y: number
}
url: string | undefined
zoom: number
}
export const LivePreviewContext = createContext<LivePreviewContextType>({
appIsReady: false,
breakpoint: undefined,
breakpoints: undefined,
fieldSchemaJSON: undefined,
iframeHasLoaded: false,
iframeRef: undefined,
isPopupOpen: false,
measuredDeviceSize: {
height: 0,
width: 0,
},
openPopupWindow: () => {},
popupRef: undefined,
previewWindowType: 'iframe',
setAppIsReady: () => {},
setBreakpoint: () => {},
setHeight: () => {},
setIframeHasLoaded: () => {},
setMeasuredDeviceSize: () => {},
setPreviewWindowType: () => {},
setSize: () => {},
setToolbarPosition: () => {},
setWidth: () => {},
@@ -58,6 +77,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
x: 0,
y: 0,
},
url: undefined,
zoom: 1,
})

View File

@@ -1,39 +1,48 @@
import { DndContext } from '@dnd-kit/core'
import React, { useCallback, useEffect } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { LivePreviewConfig } from '../../../../../exports/config'
import type { Field } from '../../../../../fields/config/types'
import type { EditViewProps } from '../../types'
import type { usePopupWindow } from '../usePopupWindow'
import { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON'
import { customCollisionDetection } from './collisionDetection'
import { LivePreviewContext } from './context'
import { sizeReducer } from './sizeReducer'
export type ToolbarProviderProps = EditViewProps & {
export type LivePreviewProviderProps = EditViewProps & {
appIsReady?: boolean
breakpoints?: LivePreviewConfig['breakpoints']
children: React.ReactNode
deviceSize?: {
height: number
width: number
}
popupState: ReturnType<typeof usePopupWindow>
isPopupOpen?: boolean
openPopupWindow?: ReturnType<typeof usePopupWindow>['openPopupWindow']
popupRef?: React.MutableRefObject<Window>
url?: string
}
export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
const { breakpoints, children } = props
export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = (props) => {
const { breakpoints, children, isPopupOpen, openPopupWindow, popupRef, url } = props
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
const [appIsReady, setAppIsReady] = useState(false)
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const [iframeHasLoaded, setIframeHasLoaded] = React.useState(false)
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
const [zoom, setZoom] = React.useState(1)
const [zoom, setZoom] = useState(1)
const [position, setPosition] = React.useState({ x: 0, y: 0 })
const [position, setPosition] = useState({ x: 0, y: 0 })
const [size, setSize] = React.useReducer(sizeReducer, { height: 0, width: 0 })
const [measuredDeviceSize, setMeasuredDeviceSize] = React.useState({
const [measuredDeviceSize, setMeasuredDeviceSize] = useState({
height: 0,
width: 0,
})
@@ -41,6 +50,22 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
const [breakpoint, setBreakpoint] =
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
const [fieldSchemaJSON] = useState(() => {
let fields: Field[]
if ('collection' in props) {
const { collection } = props
fields = collection.fields
}
if ('global' in props) {
const { global } = props
fields = global.fields
}
return fieldSchemaToJSON(fields)
})
// The toolbar needs to freely drag and drop around the page
const handleDragEnd = (ev) => {
// only update position if the toolbar is completely within the preview area
@@ -94,24 +119,70 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
}
}, [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
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data)
if (url.startsWith(event.origin) && data.type === 'payload-live-preview' && data.ready) {
setAppIsReady(true)
}
}
window.addEventListener('message', handleMessage)
return () => {
window.removeEventListener('message', handleMessage)
}
}, [url])
const handleWindowChange = useCallback(
(type: 'iframe' | 'popup') => {
setAppIsReady(false)
setPreviewWindowType(type)
if (type === 'popup') openPopupWindow()
},
[openPopupWindow],
)
// when the user closes the popup window, switch back to the iframe
// the `usePopupWindow` reports the `isPopupOpen` state for us to use here
useEffect(() => {
if (!isPopupOpen) {
handleWindowChange('iframe')
}
}, [isPopupOpen, handleWindowChange])
return (
<LivePreviewContext.Provider
value={{
appIsReady,
breakpoint,
breakpoints,
fieldSchemaJSON,
iframeHasLoaded,
iframeRef,
isPopupOpen,
measuredDeviceSize,
openPopupWindow,
popupRef,
previewWindowType,
setAppIsReady,
setBreakpoint,
setHeight,
setIframeHasLoaded,
setMeasuredDeviceSize,
setPreviewWindowType: handleWindowChange,
setSize,
setToolbarPosition: setPosition,
setWidth,
setZoom,
size,
toolbarPosition: position,
url,
zoom,
}}
>

View File

@@ -1,6 +1,7 @@
@import '../../../../scss/styles.scss';
.live-preview-iframe {
background-color: white;
border: 0;
width: 100%;
height: 100%;

View File

@@ -1,6 +1,7 @@
@import '../../../../scss/styles.scss';
.live-preview-window {
background-color: var(--theme-bg);
width: 60%;
flex-shrink: 0;
flex-grow: 0;

View File

@@ -1,14 +1,9 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect } from 'react'
import type { LivePreviewConfig } from '../../../../../exports/config'
import type { Field } from '../../../../../fields/config/types'
import type { EditViewProps } from '../../types'
import type { usePopupWindow } from '../usePopupWindow'
import { fieldSchemaToJSON } from '../../../../../utilities/fieldSchemaToJSON'
import { useAllFormFields } from '../../../forms/Form/context'
import reduceFieldsToValues from '../../../forms/Form/reduceFieldsToValues'
import { LivePreviewProvider } from '../Context'
import { useLivePreviewContext } from '../Context/context'
import { DeviceContainer } from '../Device'
import { IFrame } from '../IFrame'
@@ -17,93 +12,81 @@ import './index.scss'
const baseClass = 'live-preview-window'
const Preview: React.FC<
EditViewProps & {
popupState: ReturnType<typeof usePopupWindow>
url?: string
}
> = (props) => {
export const LivePreview: React.FC<EditViewProps> = (props) => {
const {
popupState: { isPopupOpen, popupHasLoaded, popupRef },
appIsReady,
iframeHasLoaded,
iframeRef,
popupRef,
previewWindowType,
setIframeHasLoaded,
url,
} = props
} = useLivePreviewContext()
const { iframeHasLoaded, iframeRef, setIframeHasLoaded } = useLivePreviewContext()
const { breakpoint, fieldSchemaJSON } = useLivePreviewContext()
const { breakpoint } = useLivePreviewContext()
const prevWindowType =
React.useRef<ReturnType<typeof useLivePreviewContext>['previewWindowType']>()
const [fields] = useAllFormFields()
const [fieldSchemaJSON] = useState(() => {
let fields: Field[]
if ('collection' in props) {
const { collection } = props
fields = collection.fields
}
if ('global' in props) {
const { global } = props
fields = global.fields
}
return fieldSchemaToJSON(fields)
})
// 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 (fields && window && 'postMessage' in window) {
// For performance, do no reduce fields to values until after the iframe or popup has loaded
if (fields && window && 'postMessage' in window && appIsReady) {
const values = reduceFieldsToValues(fields, true)
// TODO: only send `fieldSchemaToJSON` one time
// 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 = previewWindowType
const message = JSON.stringify({
data: values,
fieldSchemaJSON,
fieldSchemaJSON: shouldSendSchema ? fieldSchemaJSON : undefined,
type: 'payload-live-preview',
})
// external window
if (isPopupOpen) {
setIframeHasLoaded(false)
if (popupRef.current) {
popupRef.current.postMessage(message, url)
}
// Post message to external popup window
if (previewWindowType === 'popup' && popupRef.current) {
popupRef.current.postMessage(message, url)
}
// embedded iframe
if (!isPopupOpen) {
if (iframeHasLoaded && iframeRef.current) {
iframeRef.current.contentWindow?.postMessage(message, url)
}
// Post message to embedded iframe
if (previewWindowType === 'iframe' && iframeRef.current) {
iframeRef.current.contentWindow?.postMessage(message, url)
}
}
}, [
fields,
url,
iframeHasLoaded,
isPopupOpen,
previewWindowType,
popupRef,
popupHasLoaded,
appIsReady,
iframeRef,
setIframeHasLoaded,
fieldSchemaJSON,
])
if (!isPopupOpen) {
if (previewWindowType === 'iframe') {
return (
<div
className={[
baseClass,
isPopupOpen && `${baseClass}--popup-open`,
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
]
.filter(Boolean)
.join(' ')}
>
<div className={`${baseClass}__wrapper`}>
<LivePreviewToolbar {...props} iframeRef={iframeRef} url={url} />
<LivePreviewToolbar {...props} />
<div className={`${baseClass}__main`}>
<DeviceContainer>
<IFrame ref={iframeRef} setIframeHasLoaded={setIframeHasLoaded} url={url} />
@@ -114,29 +97,3 @@ const Preview: React.FC<
)
}
}
export const LivePreview: React.FC<
EditViewProps & {
livePreviewConfig?: LivePreviewConfig
popupState: ReturnType<typeof usePopupWindow>
url?: string
}
> = (props) => {
const { livePreviewConfig, url } = props
const breakpoints: LivePreviewConfig['breakpoints'] = [
...(livePreviewConfig?.breakpoints || []),
{
name: 'responsive',
height: '100%',
label: 'Responsive',
width: '100%',
},
]
return (
<LivePreviewProvider {...props} breakpoints={breakpoints} url={url}>
<Preview {...props} />
</LivePreviewProvider>
)
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import type { LivePreviewToolbarProps } from '..'
import type { EditViewProps } from '../../../types'
import { X } from '../../../..'
import { ExternalLinkIcon } from '../../../../graphics/ExternalLink'
@@ -10,13 +10,9 @@ import './index.scss'
const baseClass = 'live-preview-toolbar-controls'
export const ToolbarControls: React.FC<LivePreviewToolbarProps> = (props) => {
const { breakpoint, breakpoints, setBreakpoint, setZoom, zoom } = useLivePreviewContext()
const {
popupState: { openPopupWindow },
url,
} = props
export const ToolbarControls: React.FC<EditViewProps> = () => {
const { breakpoint, breakpoints, setBreakpoint, setPreviewWindowType, setZoom, url, zoom } =
useLivePreviewContext()
return (
<div className={baseClass}>
@@ -57,7 +53,15 @@ export const ToolbarControls: React.FC<LivePreviewToolbarProps> = (props) => {
<option value={150}>150%</option>
<option value={200}>200%</option>
</select>
<a className={`${baseClass}__external`} href={url} onClick={openPopupWindow} type="button">
<a
className={`${baseClass}__external`}
href={url}
onClick={(e) => {
e.preventDefault()
setPreviewWindowType('popup')
}}
type="button"
>
<ExternalLinkIcon />
</a>
</div>

View File

@@ -1,7 +1,7 @@
import { useDraggable } from '@dnd-kit/core'
import React from 'react'
import type { ToolbarProviderProps } from '../Context'
import type { EditViewProps } from '../../types'
import DragHandle from '../../../icons/Drag'
import { useLivePreviewContext } from '../Context/context'
@@ -10,11 +10,7 @@ import './index.scss'
const baseClass = 'live-preview-toolbar'
export type LivePreviewToolbarProps = Omit<ToolbarProviderProps, 'children'> & {
iframeRef: React.RefObject<HTMLIFrameElement>
}
const DraggableToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
const DraggableToolbar: React.FC<EditViewProps> = (props) => {
const { toolbarPosition } = useLivePreviewContext()
const { attributes, listeners, setNodeRef, transform } = useDraggable({
@@ -50,7 +46,7 @@ const DraggableToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
)
}
const StaticToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
const StaticToolbar: React.FC<EditViewProps> = (props) => {
return (
<div className={[baseClass, `${baseClass}--static`].join(' ')}>
<ToolbarControls {...props} />
@@ -59,7 +55,7 @@ const StaticToolbar: React.FC<LivePreviewToolbarProps> = (props) => {
}
export const LivePreviewToolbar: React.FC<
LivePreviewToolbarProps & {
EditViewProps & {
draggable?: boolean
}
> = (props) => {

View File

@@ -3,6 +3,7 @@
.live-preview {
width: 100%;
display: flex;
--gradient: linear-gradient(to left, rgba(0, 0, 0, 0.04) 0%, transparent 100%);
[dir='rtl'] & {
flex-direction: row-reverse;
@@ -34,7 +35,7 @@
right: 0;
width: calc(var(--base) * 2);
height: 100%;
background: linear-gradient(to left, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0) 100%);
background: var(--gradient);
pointer-events: none;
z-index: -1;
}
@@ -58,6 +59,10 @@
&__main {
min-height: initial;
width: 100%;
&::after {
display: none;
}
}
&__form {
@@ -77,3 +82,9 @@
}
}
}
html[data-theme='dark'] {
.live-preview {
--gradient: linear-gradient(to left, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 100%);
}
}

View File

@@ -17,47 +17,17 @@ import { useDocumentInfo } from '../../utilities/DocumentInfo'
import { useLocale } from '../../utilities/Locale'
import Meta from '../../utilities/Meta'
import { SetStepNav } from '../collections/Edit/SetStepNav'
import { LivePreviewProvider } from './Context'
import { useLivePreviewContext } from './Context/context'
import { LivePreview } from './Preview'
import './index.scss'
import { usePopupWindow } from './usePopupWindow'
const baseClass = 'live-preview'
export const LivePreviewView: React.FC<EditViewProps> = (props) => {
const PreviewView: React.FC<EditViewProps> = (props) => {
const { i18n, t } = useTranslation('general')
const config = useConfig()
const documentInfo = useDocumentInfo()
const locale = useLocale()
let livePreviewConfig: LivePreviewConfig = config?.admin?.livePreview
if ('collection' in props) {
livePreviewConfig = {
...(livePreviewConfig || {}),
...(props?.collection.admin.livePreview || {}),
}
}
if ('global' in props) {
livePreviewConfig = {
...(livePreviewConfig || {}),
...(props?.global.admin.livePreview || {}),
}
}
const url =
typeof livePreviewConfig?.url === 'function'
? livePreviewConfig?.url({
data: props?.data,
documentInfo,
locale,
})
: livePreviewConfig?.url
const popupState = usePopupWindow({
eventType: 'payload-live-preview',
url,
})
const { previewWindowType } = useLivePreviewContext()
const { apiURL, data, permissions } = props
@@ -113,14 +83,14 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
permissions={permissions}
/>
<div
className={[baseClass, popupState?.isPopupOpen && `${baseClass}--detached`]
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
.filter(Boolean)
.join(' ')}
>
<div
className={[
`${baseClass}__main`,
popupState?.isPopupOpen && `${baseClass}__main--popup-open`,
previewWindowType === 'popup' && `${baseClass}__main--popup-open`,
]
.filter(Boolean)
.join(' ')}
@@ -148,13 +118,67 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
)}
</Gutter>
</div>
<LivePreview
{...props}
livePreviewConfig={livePreviewConfig}
popupState={popupState}
url={url}
/>
<LivePreview {...props} />
</div>
</Fragment>
)
}
export const LivePreviewView: React.FC<EditViewProps> = (props) => {
const config = useConfig()
const documentInfo = useDocumentInfo()
const locale = useLocale()
let livePreviewConfig: LivePreviewConfig = config?.admin?.livePreview
if ('collection' in props) {
livePreviewConfig = {
...(livePreviewConfig || {}),
...(props?.collection.admin.livePreview || {}),
}
}
if ('global' in props) {
livePreviewConfig = {
...(livePreviewConfig || {}),
...(props?.global.admin.livePreview || {}),
}
}
const url =
typeof livePreviewConfig?.url === 'function'
? livePreviewConfig?.url({
data: props?.data,
documentInfo,
locale,
})
: livePreviewConfig?.url
const breakpoints: LivePreviewConfig['breakpoints'] = [
...(livePreviewConfig?.breakpoints || []),
{
name: 'responsive',
height: '100%',
label: 'Responsive',
width: '100%',
},
]
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
eventType: 'payload-live-preview',
url,
})
return (
<LivePreviewProvider
{...props}
breakpoints={breakpoints}
isPopupOpen={isPopupOpen}
openPopupWindow={openPopupWindow}
popupRef={popupRef}
url={url}
>
<PreviewView {...props} />
</LivePreviewProvider>
)
}

View File

@@ -19,17 +19,14 @@ export const usePopupWindow = (props: {
url: string
}): {
isPopupOpen: boolean
openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void
popupHasLoaded: boolean
openPopupWindow: () => void
popupRef?: React.MutableRefObject<Window | null>
} => {
const { eventType, onMessage, url } = props
const isReceivingMessage = useRef(false)
const [isOpen, setIsOpen] = useState(false)
const [popupHasLoaded, setPopupHasLoaded] = useState(false)
const { serverURL } = useConfig()
const popupRef = useRef<Window | null>(null)
const hasAttachedMessageListener = useRef(false)
// Optionally broadcast messages back out to the parent component
useEffect(() => {
@@ -65,8 +62,10 @@ export const usePopupWindow = (props: {
// Customize the size, position, and style of the popup window
const openPopupWindow = useCallback(
(e) => {
e.preventDefault()
(e?: MouseEvent) => {
if (e) {
e.preventDefault()
}
const features = {
height: 700,
@@ -106,27 +105,6 @@ export const usePopupWindow = (props: {
[url],
)
// the only cross-origin way of detecting when a popup window has loaded
// we catch a message event that the site rendered within the popup window fires
// there is no way in js to add an event listener to a popup window across domains
useEffect(() => {
if (hasAttachedMessageListener.current) return
hasAttachedMessageListener.current = true
window.addEventListener('message', (event) => {
const data = JSON.parse(event.data)
if (
url.startsWith(event.origin) &&
data.type === eventType &&
data.popupReady &&
!popupHasLoaded
) {
setPopupHasLoaded(true)
}
})
}, [url, eventType, popupHasLoaded])
// this is the most stable and widely supported way to check if a popup window is no longer open
// we poll its ref every x ms and use the popup window's `closed` property
useEffect(() => {
@@ -137,7 +115,6 @@ export const usePopupWindow = (props: {
if (popupRef.current.closed) {
clearInterval(timer)
setIsOpen(false)
setPopupHasLoaded(false)
}
}, 1000)
} else {
@@ -154,7 +131,6 @@ export const usePopupWindow = (props: {
return {
isPopupOpen: isOpen,
openPopupWindow,
popupHasLoaded,
popupRef,
}
}

View File

@@ -3,7 +3,10 @@ import { useEffect, useRef, useState } from 'react'
type Intersect = [setNode: React.Dispatch<Element>, entry: IntersectionObserverEntry]
const useIntersect = ({ root = null, rootMargin = '0px', threshold = 0 } = {}): Intersect => {
const useIntersect = (
{ root = null, rootMargin = '0px', threshold = 0 } = {},
disable?: boolean,
): Intersect => {
const [entry, updateEntry] = useState<IntersectionObserverEntry>()
const [node, setNode] = useState(null)
@@ -16,13 +19,16 @@ const useIntersect = ({ root = null, rootMargin = '0px', threshold = 0 } = {}):
)
useEffect(() => {
if (disable) {
return
}
const { current: currentObserver } = observer
currentObserver.disconnect()
if (node) currentObserver.observe(node)
return () => currentObserver.disconnect()
}, [node])
}, [node, disable])
return [setNode, entry]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "0.1.12",
"version": "0.1.13",
"description": "The officially supported Lexical richtext adapter for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",

View File

@@ -163,6 +163,7 @@ export const BlockContent: React.FC<Props> = (props) => {
path: createNestedFieldPath(null, field),
}))}
fieldTypes={field.fieldTypes}
forceRender
margins="small"
permissions={field.permissions?.blocks?.[fields?.data?.blockType]?.fields}
readOnly={field.admin.readOnly}

View File

@@ -81,8 +81,11 @@ ${packagesToRelease
try {
console.log(chalk.bold(`\n\nPublishing ${shortName}...\n\n`))
execSync(`npm --no-git-tag-version --prefix ${packagePath} version ${bump}`, execOpts)
let npmVersionCmd = `npm --no-git-tag-version --prefix ${packagePath} version ${bump}`
if (tag !== 'latest') {
npmVersionCmd += ` --preid ${tag}`
}
execSync(npmVersionCmd, execOpts)
execSync(`git add ${packagePath}/package.json`, execOpts)
const packageObj = await fse.readJson(`${packagePath}/package.json`)
@@ -91,7 +94,13 @@ ${packagesToRelease
const tagName = `${shortName}/${newVersion}`
execSync(`git commit -m "chore(release): ${tagName}"`, execOpts)
execSync(`git tag -a ${tagName} -m "${tagName}"`, execOpts)
execSync(`pnpm publish -C ${packagePath} --no-git-checks`, execOpts)
let publishCmd = `pnpm publish -C ${packagePath} --no-git-checks`
if (tag !== 'latest') {
publishCmd += ` --tag ${tag}`
}
execSync(publishCmd, execOpts)
results.push({ name: shortName, success: true })
} catch (error) {
console.error(chalk.bold.red(`ERROR: ${error.message}`))

View File

@@ -9,7 +9,9 @@ const adminsAndUser: Access = ({ req: { user } }) => {
}
return {
id: user.id,
id: {
equals: user.id,
},
}
}

View File

@@ -9,7 +9,9 @@ const adminsAndUser: Access = ({ req: { user } }) => {
}
return {
id: user.id,
id: {
equals: user.id,
},
}
}

View File

@@ -0,0 +1,10 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'title',
},
fields: [],
}

View File

@@ -1,20 +1,12 @@
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
import { devUser } from '../credentials'
import Categories from './collections/Categories'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { Posts, postsSlug } from './collections/Posts'
import { Posts } from './collections/Posts'
import { Users } from './collections/Users'
import { Footer } from './globals/Footer'
import { Header } from './globals/Header'
import { footer } from './seed/footer'
import { header } from './seed/header'
import { home } from './seed/home'
import { post1 } from './seed/post-1'
import { post2 } from './seed/post-2'
import { post3 } from './seed/post-3'
import { postsPage } from './seed/posts-page'
import { seed } from './seed'
export const pagesSlug = 'pages'
@@ -40,92 +32,7 @@ export default buildConfigWithDefaults({
},
cors: ['http://localhost:3001'],
csrf: ['http://localhost:3001'],
collections: [
{
slug: 'users',
auth: true,
admin: {
useAsTitle: 'title',
},
fields: [],
},
Pages,
Posts,
Categories,
Media,
],
collections: [Users, Pages, Posts, Categories, Media],
globals: [Header, Footer],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
const media = await payload.create({
collection: 'media',
filePath: path.resolve(__dirname, 'image-1.jpg'),
data: {
alt: 'Image 1',
},
})
const mediaID = payload.db.defaultIDType === 'number' ? media.id : `"${media.id}"`
const [post1Doc, post2Doc, post3Doc] = await Promise.all([
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post1).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
}),
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post2).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
}),
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post3).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
}),
])
const postsPageDoc = await payload.create({
collection: pagesSlug,
data: JSON.parse(JSON.stringify(postsPage).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
})
let postsPageDocID = postsPageDoc.id
let post1DocID = post1Doc.id
let post2DocID = post2Doc.id
let post3DocID = post3Doc.id
if (payload.db.defaultIDType !== 'number') {
postsPageDocID = `"${postsPageDoc.id}"`
post1DocID = `"${post1Doc.id}"`
post2DocID = `"${post2Doc.id}"`
post3DocID = `"${post3Doc.id}"`
}
await payload.create({
collection: pagesSlug,
data: JSON.parse(
JSON.stringify(home)
.replace(/"\{\{MEDIA_ID\}\}"/g, mediaID)
.replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)
.replace(/"\{\{POST_1_ID\}\}"/g, post1DocID)
.replace(/"\{\{POST_2_ID\}\}"/g, post2DocID)
.replace(/"\{\{POST_3_ID\}\}"/g, post3DocID),
),
})
await payload.updateGlobal({
slug: 'header',
data: JSON.parse(JSON.stringify(header).replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)),
})
await payload.updateGlobal({
slug: 'footer',
data: JSON.parse(JSON.stringify(footer)),
})
},
onInit: seed,
})

View File

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -0,0 +1,88 @@
import path from 'path'
import type { Config } from '../../../packages/payload/src/config/types'
import { devUser } from '../../credentials'
import { postsSlug } from '../collections/Posts'
import { pagesSlug } from '../config'
import { footer } from './footer'
import { header } from './header'
import { home } from './home'
import { post1 } from './post-1'
import { post2 } from './post-2'
import { post3 } from './post-3'
import { postsPage } from './posts-page'
export const seed: Config['onInit'] = async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
const media = await payload.create({
collection: 'media',
filePath: path.resolve(__dirname, 'image-1.jpg'),
data: {
alt: 'Image 1',
},
})
const mediaID = payload.db.defaultIDType === 'number' ? media.id : `"${media.id}"`
const [post1Doc, post2Doc, post3Doc] = await Promise.all([
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post1).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
}),
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post2).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
}),
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post3).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
}),
])
const postsPageDoc = await payload.create({
collection: pagesSlug,
data: JSON.parse(JSON.stringify(postsPage).replace(/"\{\{IMAGE\}\}"/g, mediaID)),
})
let postsPageDocID = postsPageDoc.id
let post1DocID = post1Doc.id
let post2DocID = post2Doc.id
let post3DocID = post3Doc.id
if (payload.db.defaultIDType !== 'number') {
postsPageDocID = `"${postsPageDoc.id}"`
post1DocID = `"${post1Doc.id}"`
post2DocID = `"${post2Doc.id}"`
post3DocID = `"${post3Doc.id}"`
}
await payload.create({
collection: pagesSlug,
data: JSON.parse(
JSON.stringify(home)
.replace(/"\{\{MEDIA_ID\}\}"/g, mediaID)
.replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)
.replace(/"\{\{POST_1_ID\}\}"/g, post1DocID)
.replace(/"\{\{POST_2_ID\}\}"/g, post2DocID)
.replace(/"\{\{POST_3_ID\}\}"/g, post3DocID),
),
})
await payload.updateGlobal({
slug: 'header',
data: JSON.parse(JSON.stringify(header).replace(/"\{\{POSTS_PAGE_ID\}\}"/g, postsPageDocID)),
})
await payload.updateGlobal({
slug: 'footer',
data: JSON.parse(JSON.stringify(footer)),
})
}