chore: handles live preview data (#3440)

This commit is contained in:
Jacob Fletcher
2023-10-05 10:14:57 -04:00
committed by GitHub
parent f989e02a85
commit 0ac36069bd
12 changed files with 588 additions and 20 deletions

View File

@@ -1,9 +1,11 @@
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import type { LivePreview as LivePreviewType } 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'
@@ -33,13 +35,31 @@ const Preview: React.FC<
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) {
const values = reduceFieldsToValues(fields)
const message = JSON.stringify({ data: values, type: 'livePreview' })
const values = reduceFieldsToValues(fields, true)
// TODO: only send `fieldSchemaToJSON` one time
const message = JSON.stringify({ data: values, fieldSchemaJSON, type: 'livePreview' })
// external window
if (isPopupOpen) {
@@ -66,6 +86,7 @@ const Preview: React.FC<
popupHasLoaded,
iframeRef,
setIframeHasLoaded,
fieldSchemaJSON,
])
if (!isPopupOpen) {

View File

@@ -1,31 +1,45 @@
import { useCallback, useEffect, useState } from 'react'
import { mergeLivePreviewData } from './mergeData'
// To prevent the flicker of missing data on initial load,
// you can pass in the initial page data from the server
// To prevent the flicker of stale data while the post message is being sent,
// you can conditionally render loading UI based on the `isLoading` state
export const useLivePreview = (props: {
initialPage: any
export const useLivePreview = <T extends any>(props: {
depth?: number
initialPage: T
serverURL: string
}): {
data: any
data: T
isLoading: boolean
} => {
const { initialPage, serverURL } = props
const [data, setData] = useState<any>(initialPage)
const { depth = 0, initialPage, serverURL } = props
const [data, setData] = useState<T>(initialPage)
const [isLoading, setIsLoading] = useState<boolean>(true)
const handleMessage = useCallback(
(event: MessageEvent) => {
async (event: MessageEvent) => {
if (event.origin === serverURL && event.data) {
const eventData = JSON.parse(event?.data)
if (eventData.type === 'livePreview') {
setData(eventData?.data)
const mergedData = await mergeLivePreviewData<T>({
depth,
existingData: data,
fieldSchema: eventData.fieldSchemaJSON,
incomingData: eventData.data,
serverURL,
})
setData(mergedData)
setIsLoading(false)
}
}
},
[serverURL],
[serverURL, data, depth],
)
useEffect(() => {

View File

@@ -0,0 +1,37 @@
import { traverseFields } from './traverseFields'
export type MergeLiveDataArgs<T> = {
apiRoute?: string
depth: number
existingData: T
fieldSchema: Record<string, unknown>[]
incomingData: T
serverURL: string
}
export const mergeLivePreviewData = async <T>({
apiRoute,
depth,
existingData,
fieldSchema,
incomingData,
serverURL,
}: MergeLiveDataArgs<T>): Promise<T> => {
const result = { ...existingData }
const populationPromises: Promise<void>[] = []
traverseFields({
apiRoute,
depth,
fieldSchema,
incomingData,
populationPromises,
result,
serverURL,
})
await Promise.all(populationPromises)
return result
}

View File

@@ -0,0 +1,26 @@
type Args = {
accessor: number | string
apiRoute?: string
collection: string
depth: number
id: number | string
ref: Record<string, unknown>
serverURL: string
}
export const promise = async ({
id,
accessor,
apiRoute,
collection,
depth,
ref,
serverURL,
}: Args): Promise<void> => {
// TODO: get dynamic `api` route from config
const res: any = await fetch(
`${serverURL}${apiRoute || '/api'}/${collection}/${id}?depth=${depth}`,
).then((res) => res.json())
ref[accessor] = res
}

View File

@@ -0,0 +1,231 @@
import { promise } from './promise'
type Args<T> = {
apiRoute?: string
depth: number
fieldSchema: Record<string, unknown>[]
incomingData: T
populationPromises: Promise<void>[]
result: T
serverURL: string
}
export const traverseFields = <T>({
apiRoute,
depth,
fieldSchema,
incomingData,
populationPromises,
result,
serverURL,
}: Args<T>): void => {
fieldSchema.forEach((field) => {
if ('name' in field && typeof field.name === 'string') {
// TODO: type this
const fieldName = field.name
switch (field.type) {
case 'array':
if (Array.isArray(incomingData[fieldName])) {
result[fieldName] = incomingData[fieldName].map((row, i) => {
const hasExistingRow =
Array.isArray(result[fieldName]) &&
typeof result[fieldName][i] === 'object' &&
result[fieldName][i] !== null
const newRow = hasExistingRow ? { ...result[fieldName][i] } : {}
traverseFields({
apiRoute,
depth,
fieldSchema: field.fields as Record<string, unknown>[], // TODO: type this
incomingData: row,
populationPromises,
result: newRow,
serverURL,
})
return newRow
})
}
break
case 'blocks':
if (Array.isArray(incomingData[fieldName])) {
result[fieldName] = incomingData[fieldName].map((row, i) => {
const matchedBlock = field.blocks[row.blockType]
const hasExistingRow =
Array.isArray(result[fieldName]) &&
typeof result[fieldName][i] === 'object' &&
result[fieldName][i] !== null &&
result[fieldName][i].blockType === row.blockType
const newRow = hasExistingRow
? { ...result[fieldName][i] }
: {
blockType: matchedBlock.slug,
}
traverseFields({
apiRoute,
depth,
fieldSchema: matchedBlock.fields as Record<string, unknown>[], // TODO: type this
incomingData: row,
populationPromises,
result: newRow,
serverURL,
})
return newRow
})
}
break
case 'tab':
case 'group':
if (!result[fieldName]) {
result[fieldName] = {}
}
traverseFields({
apiRoute,
depth,
fieldSchema: field.fields as Record<string, unknown>[], // TODO: type this
incomingData: incomingData[fieldName] || {},
populationPromises,
result: result[fieldName],
serverURL,
})
break
case 'upload':
case 'relationship':
if (field.hasMany && Array.isArray(incomingData[fieldName])) {
const existingValue = Array.isArray(result[fieldName]) ? [...result[fieldName]] : []
result[fieldName] = Array.isArray(result[fieldName])
? [...result[fieldName]].slice(0, incomingData[fieldName].length)
: []
incomingData[fieldName].forEach((relation, i) => {
// Handle `hasMany` polymorphic
if (Array.isArray(field.relationTo)) {
const existingID = existingValue[i]?.value?.id
if (
existingID !== relation.value ||
existingValue[i]?.relationTo !== relation.relationTo
) {
result[fieldName][i] = {
relationTo: relation.relationTo,
}
populationPromises.push(
promise({
id: relation.value,
accessor: 'value',
apiRoute,
collection: relation.relationTo,
depth,
ref: result[fieldName][i],
serverURL,
}),
)
}
} else {
// Handle `hasMany` singular
const existingID = existingValue[i]?.id
if (existingID !== relation) {
populationPromises.push(
promise({
id: relation,
accessor: i,
apiRoute,
collection: String(field.relationTo),
depth,
ref: result[fieldName],
serverURL,
}),
)
}
}
})
} else {
// Handle `hasOne` polymorphic
if (Array.isArray(field.relationTo)) {
const hasNewValue =
typeof incomingData[fieldName] === 'object' && incomingData[fieldName] !== null
const hasOldValue =
typeof result[fieldName] === 'object' && result[fieldName] !== null
const newValue = hasNewValue ? incomingData[fieldName].value : ''
const newRelation = hasNewValue ? incomingData[fieldName].relationTo : ''
const oldValue = hasOldValue ? result[fieldName].value : ''
const oldRelation = hasOldValue ? result[fieldName].relationTo : ''
if (newValue !== oldValue || newRelation !== oldRelation) {
if (newValue) {
if (!result[fieldName]) {
result[fieldName] = {
relationTo: newRelation,
}
}
populationPromises.push(
promise({
id: newValue,
accessor: 'value',
apiRoute,
collection: newRelation,
depth,
ref: result[fieldName],
serverURL,
}),
)
}
} else {
result[fieldName] = null
}
} else {
const hasNewValue =
typeof incomingData[fieldName] === 'object' && incomingData[fieldName] !== null
const hasOldValue =
typeof result[fieldName] === 'object' && result[fieldName] !== null
const newValue = hasNewValue ? incomingData[fieldName].value : ''
const oldValue = hasOldValue ? result[fieldName].value : ''
if (newValue !== oldValue) {
if (newValue) {
populationPromises.push(
promise({
id: newValue,
accessor: fieldName,
apiRoute,
collection: String(field.relationTo),
depth,
ref: result as Record<string, unknown>,
serverURL,
}),
)
}
} else {
result[fieldName] = null
}
}
}
break
default:
result[fieldName] = incomingData[fieldName]
}
}
})
return null
}

View File

@@ -65,6 +65,59 @@ export default buildConfigWithDefaults({
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'layout',
type: 'blocks',
blocks: [
{
slug: 'hero',
labels: {
singular: 'Hero',
plural: 'Hero',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
],
},
],
},
{
name: 'featuredPosts',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
},
],
},
{
slug: 'posts',
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
},
@@ -78,12 +131,27 @@ export default buildConfigWithDefaults({
},
})
const post1 = await payload.create({
collection: 'posts',
data: {
title: 'Post 1',
},
})
await payload.create({
collection: slug,
data: {
title: 'Hello, world!',
description: 'This is an example of live preview.',
slug: 'home',
layout: [
{
blockType: 'hero',
title: 'Hello, world!',
description: 'This is an example of live preview.',
},
],
featuredPosts: [post1.id],
},
})
},

View File

@@ -1,11 +1,8 @@
export type PageType = {
title?: string
description?: string
}
import { Page } from '@/payload-types'
export const PAYLOAD_SERVER_URL = 'http://localhost:3000'
export const getPage = async (slug: string): Promise<PageType> => {
export const getPage = async (slug: string): Promise<Page> => {
return await fetch(`http://localhost:3000/api/pages?where[slug][equals]=${slug}`, {
method: 'GET',
cache: 'no-store',

View File

@@ -2,11 +2,12 @@
import React, { Fragment } from 'react'
import styles from './page.module.css'
import { PAYLOAD_SERVER_URL, PageType } from './api'
import { PAYLOAD_SERVER_URL } from './api'
// The `useLivePreview` hook is imported from the monorepo for development purposes only
// in your own app you would import this hook directly from the payload package itself
// i.e. `import { useLivePreview } from 'payload'`
import { useLivePreview } from '../../../../packages/payload/src/admin/components/views/LivePreview/useLivePreview'
import { Page as PageType } from '@/payload-types'
export type Props = {
initialPage: PageType
@@ -14,15 +15,48 @@ export type Props = {
export const Page: React.FC<Props> = (props) => {
const { initialPage } = props
const { data, isLoading } = useLivePreview({ initialPage, serverURL: PAYLOAD_SERVER_URL })
const { data, isLoading } = useLivePreview<PageType>({
initialPage,
serverURL: PAYLOAD_SERVER_URL,
})
return (
<main className={styles.main}>
{isLoading && <Fragment>Loading...</Fragment>}
{!isLoading && (
<Fragment>
<h1>{data?.title}</h1>
<p>{data?.description}</p>
<h1>{data.title}</h1>
<p>{data.description}</p>
{data.layout && (
<div>
<p>Blocks</p>
<div className={styles.blocks}>
{data.layout.map((block, index) => {
const { title, description } = block
return (
<div key={index}>
<h2>{title}</h2>
<p>{description}</p>
</div>
)
})}
</div>
</div>
)}
<br />
<hr />
<br />
{data.featuredPosts && (
<div>
<p>Featured Posts</p>
<ul className={styles['featured-posts']}>
{data.featuredPosts.map((post, index) => (
<li key={index}>{typeof post === 'string' ? post : post.id}</li>
))}
</ul>
</div>
)}
</Fragment>
)}
</main>

View File

@@ -10,3 +10,9 @@
.main > *:last-child {
margin-bottom: 0;
}
.featured-posts {
margin: 0;
padding: 0;
list-style: none;
}

View File

@@ -1,6 +1,24 @@
import { getPage } from './api'
import { Page } from './page.client'
import { notFound } from 'next/navigation'
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: { id: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const page = await getPage('home')
return {
title: page.title,
description: page.description,
}
}
export default async function Home() {
const page = await getPage('home')

View File

@@ -0,0 +1,100 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User
pages: Page
posts: Post
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}
globals: {}
}
export interface User {
id: string
updatedAt: string
createdAt: string
email: string
resetPasswordToken?: string
resetPasswordExpiration?: string
salt?: string
hash?: string
loginAttempts?: number
lockUntil?: string
password?: string
}
export interface Page {
id: string
title: string
description: string
slug: string
layout?: {
title: string
description: string
id?: string
blockName?: string
blockType: 'hero'
}[]
featuredPosts?: string[] | Post[]
updatedAt: string
createdAt: string
}
export interface Post {
id: string
title: string
updatedAt: string
createdAt: string
}
export interface PayloadPreference {
id: string
user: {
relationTo: 'users'
value: string | User
}
key?: string
value?:
| {
[k: string]: unknown
}
| unknown[]
| string
| number
| boolean
| null
updatedAt: string
createdAt: string
}
export interface PayloadMigration {
id: string
name?: string
batch?: number
schema?:
| {
[k: string]: unknown
}
| unknown[]
| string
| number
| boolean
| null
updatedAt: string
createdAt: string
}
declare module 'payload' {
export interface GeneratedTypes {
collections: {
users: User
pages: Page
posts: Post
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}
}
}

View File

@@ -10,6 +10,7 @@ export interface Config {
collections: {
users: User
pages: Page
posts: Post
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}
@@ -33,6 +34,20 @@ export interface Page {
title: string
description: string
slug: string
layout?: {
title: string
description: string
id?: string
blockName?: string
blockType: 'hero'
}[]
featuredPosts?: string[] | Post[]
updatedAt: string
createdAt: string
}
export interface Post {
id: string
title: string
updatedAt: string
createdAt: string
}
@@ -77,6 +92,7 @@ declare module 'payload' {
collections: {
users: User
pages: Page
posts: Post
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}