diff --git a/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx b/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx index a20f2cab3..52f3cc26e 100644 --- a/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx +++ b/packages/payload/src/admin/components/views/LivePreview/Preview/index.tsx @@ -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) { diff --git a/packages/payload/src/admin/components/views/LivePreview/useLivePreview.ts b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/index.ts similarity index 61% rename from packages/payload/src/admin/components/views/LivePreview/useLivePreview.ts rename to packages/payload/src/admin/components/views/LivePreview/useLivePreview/index.ts index d91865797..706fef239 100644 --- a/packages/payload/src/admin/components/views/LivePreview/useLivePreview.ts +++ b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/index.ts @@ -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 = (props: { + depth?: number + initialPage: T serverURL: string }): { - data: any + data: T isLoading: boolean } => { - const { initialPage, serverURL } = props - const [data, setData] = useState(initialPage) + const { depth = 0, initialPage, serverURL } = props + const [data, setData] = useState(initialPage) const [isLoading, setIsLoading] = useState(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({ + depth, + existingData: data, + fieldSchema: eventData.fieldSchemaJSON, + incomingData: eventData.data, + serverURL, + }) + + setData(mergedData) + setIsLoading(false) } } }, - [serverURL], + [serverURL, data, depth], ) useEffect(() => { diff --git a/packages/payload/src/admin/components/views/LivePreview/useLivePreview/mergeData.ts b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/mergeData.ts new file mode 100644 index 000000000..4f5ae7145 --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/mergeData.ts @@ -0,0 +1,37 @@ +import { traverseFields } from './traverseFields' + +export type MergeLiveDataArgs = { + apiRoute?: string + depth: number + existingData: T + fieldSchema: Record[] + incomingData: T + serverURL: string +} + +export const mergeLivePreviewData = async ({ + apiRoute, + depth, + existingData, + fieldSchema, + incomingData, + serverURL, +}: MergeLiveDataArgs): Promise => { + const result = { ...existingData } + + const populationPromises: Promise[] = [] + + traverseFields({ + apiRoute, + depth, + fieldSchema, + incomingData, + populationPromises, + result, + serverURL, + }) + + await Promise.all(populationPromises) + + return result +} diff --git a/packages/payload/src/admin/components/views/LivePreview/useLivePreview/promise.ts b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/promise.ts new file mode 100644 index 000000000..76a473442 --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/promise.ts @@ -0,0 +1,26 @@ +type Args = { + accessor: number | string + apiRoute?: string + collection: string + depth: number + id: number | string + ref: Record + serverURL: string +} + +export const promise = async ({ + id, + accessor, + apiRoute, + collection, + depth, + ref, + serverURL, +}: Args): Promise => { + // 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 +} diff --git a/packages/payload/src/admin/components/views/LivePreview/useLivePreview/traverseFields.ts b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/traverseFields.ts new file mode 100644 index 000000000..53f4ffd51 --- /dev/null +++ b/packages/payload/src/admin/components/views/LivePreview/useLivePreview/traverseFields.ts @@ -0,0 +1,231 @@ +import { promise } from './promise' + +type Args = { + apiRoute?: string + depth: number + fieldSchema: Record[] + incomingData: T + populationPromises: Promise[] + result: T + serverURL: string +} + +export const traverseFields = ({ + apiRoute, + depth, + fieldSchema, + incomingData, + populationPromises, + result, + serverURL, +}: Args): 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[], // 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[], // 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[], // 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, + serverURL, + }), + ) + } + } else { + result[fieldName] = null + } + } + } + + break + + default: + result[fieldName] = incomingData[fieldName] + } + } + }) + + return null +} diff --git a/test/live-preview/config.ts b/test/live-preview/config.ts index 3203aae7a..ad887f9f9 100644 --- a/test/live-preview/config.ts +++ b/test/live-preview/config.ts @@ -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], }, }) }, diff --git a/test/live-preview/next-app/app/api.ts b/test/live-preview/next-app/app/api.ts index bd2835b9d..c58ae031d 100644 --- a/test/live-preview/next-app/app/api.ts +++ b/test/live-preview/next-app/app/api.ts @@ -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 => { +export const getPage = async (slug: string): Promise => { return await fetch(`http://localhost:3000/api/pages?where[slug][equals]=${slug}`, { method: 'GET', cache: 'no-store', diff --git a/test/live-preview/next-app/app/page.client.tsx b/test/live-preview/next-app/app/page.client.tsx index 355d7600b..19c606079 100644 --- a/test/live-preview/next-app/app/page.client.tsx +++ b/test/live-preview/next-app/app/page.client.tsx @@ -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) => { const { initialPage } = props - const { data, isLoading } = useLivePreview({ initialPage, serverURL: PAYLOAD_SERVER_URL }) + + const { data, isLoading } = useLivePreview({ + initialPage, + serverURL: PAYLOAD_SERVER_URL, + }) return (
{isLoading && Loading...} {!isLoading && ( -

{data?.title}

-

{data?.description}

+

{data.title}

+

{data.description}

+ {data.layout && ( +
+

Blocks

+
+ {data.layout.map((block, index) => { + const { title, description } = block + return ( +
+

{title}

+

{description}

+
+ ) + })} +
+
+ )} +
+
+
+ {data.featuredPosts && ( +
+

Featured Posts

+
    + {data.featuredPosts.map((post, index) => ( +
  • {typeof post === 'string' ? post : post.id}
  • + ))} +
+
+ )}
)}
diff --git a/test/live-preview/next-app/app/page.module.css b/test/live-preview/next-app/app/page.module.css index d08bbee05..0f74a6e59 100644 --- a/test/live-preview/next-app/app/page.module.css +++ b/test/live-preview/next-app/app/page.module.css @@ -10,3 +10,9 @@ .main > *:last-child { margin-bottom: 0; } + +.featured-posts { + margin: 0; + padding: 0; + list-style: none; +} diff --git a/test/live-preview/next-app/app/page.tsx b/test/live-preview/next-app/app/page.tsx index f463fd10e..10fd35037 100644 --- a/test/live-preview/next-app/app/page.tsx +++ b/test/live-preview/next-app/app/page.tsx @@ -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 { + const page = await getPage('home') + + return { + title: page.title, + description: page.description, + } +} export default async function Home() { const page = await getPage('home') diff --git a/test/live-preview/next-app/payload-types.ts b/test/live-preview/next-app/payload-types.ts new file mode 100644 index 000000000..e91bd7b17 --- /dev/null +++ b/test/live-preview/next-app/payload-types.ts @@ -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 + } + } +} diff --git a/test/live-preview/payload-types.ts b/test/live-preview/payload-types.ts index 0b67db742..e91bd7b17 100644 --- a/test/live-preview/payload-types.ts +++ b/test/live-preview/payload-types.ts @@ -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 }