fix(live-preview): field recursion and relationship population (#4045)

This commit is contained in:
Jacob Fletcher
2023-11-08 17:28:35 -05:00
committed by GitHub
parent c462df38f6
commit 2ad7340154
18 changed files with 1096 additions and 690 deletions

7
.vscode/launch.json vendored
View File

@@ -64,6 +64,13 @@
"NODE_ENV": "production"
}
},
{
"command": "pnpm run test:int live-preview",
"cwd": "${workspaceFolder}",
"name": "Live Preview Integration",
"request": "launch",
"type": "node-terminal"
},
{
"command": "ts-node ./packages/payload/src/bin/index.ts build",
"env": {

View File

@@ -8,6 +8,7 @@ export type MergeLiveDataArgs<T> = {
fieldSchema: ReturnType<typeof fieldSchemaToJSON>
incomingData: Partial<T>
initialData: T
returnNumberOfRequests?: boolean
serverURL: string
}
@@ -17,8 +18,13 @@ export const mergeData = async <T>({
fieldSchema,
incomingData,
initialData,
returnNumberOfRequests,
serverURL,
}: MergeLiveDataArgs<T>): Promise<T> => {
}: MergeLiveDataArgs<T>): Promise<
T & {
_numberOfRequests?: number
}
> => {
const result = { ...initialData }
const populationPromises: Promise<void>[] = []
@@ -35,5 +41,8 @@ export const mergeData = async <T>({
await Promise.all(populationPromises)
return result
return {
...result,
...(returnNumberOfRequests ? { _numberOfRequests: populationPromises.length } : {}),
}
}

View File

@@ -17,9 +17,20 @@ export const promise = async ({
ref,
serverURL,
}: Args): Promise<void> => {
const res: any = await fetch(
`${serverURL}${apiRoute || '/api'}/${collection}/${id}?depth=${depth}`,
).then((res) => res.json())
const url = `${serverURL}${apiRoute || '/api'}/${collection}/${id}?depth=${depth}`
let res: any = null
try {
res = await fetch(url, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json())
} catch (err) {
console.error(err) // eslint-disable-line no-console
}
ref[accessor] = res
}

View File

@@ -15,38 +15,39 @@ type Args<T> = {
export const traverseFields = <T>({
apiRoute,
depth,
fieldSchema,
fieldSchema: fieldSchemas,
incomingData,
populationPromises,
result,
serverURL,
}: Args<T>): void => {
fieldSchema.forEach((fieldJSON) => {
if ('name' in fieldJSON && typeof fieldJSON.name === 'string') {
const fieldName = fieldJSON.name
fieldSchemas.forEach((fieldSchema) => {
if ('name' in fieldSchema && typeof fieldSchema.name === 'string') {
const fieldName = fieldSchema.name
switch (fieldJSON.type) {
switch (fieldSchema.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
result[fieldName] = incomingData[fieldName].map((incomingRow, i) => {
if (!result[fieldName]) {
result[fieldName] = []
}
const newRow = hasExistingRow ? { ...result[fieldName][i] } : {}
if (!result[fieldName][i]) {
result[fieldName][i] = {}
}
traverseFields({
apiRoute,
depth,
fieldSchema: fieldJSON.fields,
incomingData: row,
fieldSchema: fieldSchema.fields,
incomingData: incomingRow,
populationPromises,
result: newRow,
result: result[fieldName][i],
serverURL,
})
return newRow
return result[fieldName][i]
})
}
break
@@ -54,18 +55,21 @@ export const traverseFields = <T>({
case 'blocks':
if (Array.isArray(incomingData[fieldName])) {
result[fieldName] = incomingData[fieldName].map((incomingBlock, i) => {
const incomingBlockJSON = fieldJSON.blocks[incomingBlock.blockType]
const incomingBlockJSON = fieldSchema.blocks[incomingBlock.blockType]
// Compare the index and id to determine if this block already exists in the result
// If so, we want to use the existing block as the base, otherwise take the incoming block
// Either way, we will traverse the fields of the block to populate relationships
const isExistingBlock =
Array.isArray(result[fieldName]) &&
typeof result[fieldName][i] === 'object' &&
result[fieldName][i] !== null &&
result[fieldName][i].id === incomingBlock.id
if (!result[fieldName]) {
result[fieldName] = []
}
const block = isExistingBlock ? result[fieldName][i] : incomingBlock
if (
!result[fieldName][i] ||
result[fieldName][i].id !== incomingBlock.id ||
result[fieldName][i].blockType !== incomingBlock.blockType
) {
result[fieldName][i] = {
blockType: incomingBlock.blockType,
}
}
traverseFields({
apiRoute,
@@ -73,11 +77,11 @@ export const traverseFields = <T>({
fieldSchema: incomingBlockJSON.fields,
incomingData: incomingBlock,
populationPromises,
result: block,
result: result[fieldName][i],
serverURL,
})
return block
return result[fieldName][i]
})
} else {
result[fieldName] = []
@@ -94,7 +98,7 @@ export const traverseFields = <T>({
traverseFields({
apiRoute,
depth,
fieldSchema: fieldJSON.fields,
fieldSchema: fieldSchema.fields,
incomingData: incomingData[fieldName] || {},
populationPromises,
result: result[fieldName],
@@ -105,31 +109,35 @@ export const traverseFields = <T>({
case 'upload':
case 'relationship':
if (fieldJSON.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(fieldJSON.relationTo)) {
const existingID = existingValue[i]?.value?.id
if (
existingID !== relation.value ||
existingValue[i]?.relationTo !== relation.relationTo
) {
result[fieldName][i] = {
relationTo: relation.relationTo,
// Handle `hasMany` relationships
if (fieldSchema.hasMany && Array.isArray(incomingData[fieldName])) {
if (!result[fieldName]) {
result[fieldName] = []
}
incomingData[fieldName].forEach((incomingRelation, i) => {
// Handle `hasMany` polymorphic
if (Array.isArray(fieldSchema.relationTo)) {
// if the field doesn't exist on the result, create it
// the value will be populated later
if (!result[fieldName][i]) {
result[fieldName][i] = {
relationTo: incomingRelation.relationTo,
}
}
const oldID = result[fieldName][i]?.value?.id
const oldRelation = result[fieldName][i]?.relationTo
const newID = incomingRelation.value
const newRelation = incomingRelation.relationTo
if (oldID !== newID || oldRelation !== newRelation) {
populationPromises.push(
promise({
id: relation.value,
id: incomingRelation.value,
accessor: 'value',
apiRoute,
collection: relation.relationTo,
collection: newRelation,
depth,
ref: result[fieldName][i],
serverURL,
@@ -138,15 +146,13 @@ export const traverseFields = <T>({
}
} else {
// Handle `hasMany` monomorphic
const existingID = existingValue[i]?.id
if (existingID !== relation) {
if (result[fieldName][i]?.id !== incomingRelation) {
populationPromises.push(
promise({
id: relation,
id: incomingRelation,
accessor: i,
apiRoute,
collection: String(fieldJSON.relationTo),
collection: String(fieldSchema.relationTo),
depth,
ref: result[fieldName],
serverURL,
@@ -157,29 +163,49 @@ export const traverseFields = <T>({
})
} else {
// Handle `hasOne` polymorphic
if (Array.isArray(fieldJSON.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 (Array.isArray(fieldSchema.relationTo)) {
// if the field doesn't exist on the result, create it
// the value will be populated later
if (!result[fieldName]) {
result[fieldName] = {
relationTo: newRelation,
relationTo: incomingData[fieldName]?.relationTo,
}
}
const hasNewValue =
incomingData[fieldName] &&
typeof incomingData[fieldName] === 'object' &&
incomingData[fieldName] !== null
const hasOldValue =
result[fieldName] &&
typeof result[fieldName] === 'object' &&
result[fieldName] !== null
const newID = hasNewValue
? typeof incomingData[fieldName].value === 'object'
? incomingData[fieldName].value.id
: incomingData[fieldName].value
: ''
const oldID = hasOldValue
? typeof result[fieldName].value === 'object'
? result[fieldName].value.id
: result[fieldName].value
: ''
const newRelation = hasNewValue ? incomingData[fieldName].relationTo : ''
const oldRelation = hasOldValue ? result[fieldName].relationTo : ''
// if the new value/relation is different from the old value/relation
// populate the new value, otherwise leave it alone
if (newID !== oldID || newRelation !== oldRelation) {
// if the new value is not empty, populate it
// otherwise set the value to null
if (newID) {
populationPromises.push(
promise({
id: newValue,
id: newID,
accessor: 'value',
apiRoute,
collection: newRelation,
@@ -188,34 +214,36 @@ export const traverseFields = <T>({
serverURL,
}),
)
}
} else {
result[fieldName] = null
}
}
} else {
// Handle `hasOne` monomorphic
const newID: string =
(typeof incomingData[fieldName] === 'string' && incomingData[fieldName]) ||
(typeof incomingData[fieldName] === 'object' &&
incomingData[fieldName] !== null &&
const newID: number | string | undefined =
(incomingData[fieldName] &&
typeof incomingData[fieldName] === 'object' &&
incomingData[fieldName].id) ||
''
incomingData[fieldName]
const oldID: string =
(typeof result[fieldName] === 'string' && result[fieldName]) ||
(typeof result[fieldName] === 'object' &&
result[fieldName] !== null &&
const oldID: number | string | undefined =
(result[fieldName] &&
typeof result[fieldName] === 'object' &&
result[fieldName].id) ||
''
result[fieldName]
// if the new value is different from the old value
// populate the new value, otherwise leave it alone
if (newID !== oldID) {
// if the new value is not empty, populate it
// otherwise set the value to null
if (newID) {
populationPromises.push(
promise({
id: newID,
accessor: fieldName,
apiRoute,
collection: String(fieldJSON.relationTo),
collection: String(fieldSchema.relationTo),
depth,
ref: result as Record<string, unknown>,
serverURL,

View File

@@ -1,5 +1,5 @@
import { DndContext } from '@dnd-kit/core'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import type { LivePreviewConfig } from '../../../../../exports/config'
import type { Field } from '../../../../../fields/config/types'

View File

@@ -71,5 +71,47 @@ export const Pages: CollectionConfig = {
},
],
},
// Hidden fields for testing purposes
{
name: 'relationshipPolyHasMany',
type: 'relationship',
relationTo: ['posts'],
hasMany: true,
admin: {
hidden: true,
},
},
{
name: 'relationshipMonoHasMany',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
admin: {
hidden: true,
},
},
{
name: 'relationshipMonoHasOne',
type: 'relationship',
relationTo: 'posts',
admin: {
hidden: true,
},
},
{
name: 'arrayOfRelationships',
type: 'array',
admin: {
hidden: true,
disabled: true,
},
fields: [
{
name: 'relationshipWithinArray',
type: 'relationship',
relationTo: 'posts',
},
],
},
],
}

View File

@@ -76,7 +76,7 @@ const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } =
name: 'reference',
label: 'Document to link to',
type: 'relationship',
relationTo: ['pages'],
relationTo: ['posts', 'pages'],
required: true,
maxDepth: 1,
admin: {

View File

@@ -1,6 +1,6 @@
import path from 'path'
import type { Media, Page } from './payload-types'
import type { Media, Page, Post } from './payload-types'
import { handleMessage } from '../../packages/live-preview/src/handleMessage'
import { mergeData } from '../../packages/live-preview/src/mergeData'
@@ -10,20 +10,21 @@ import { fieldSchemaToJSON } from '../../packages/payload/src/utilities/fieldSch
import { initPayloadTest } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
import { Pages } from './collections/Pages'
import { postsSlug } from './collections/Posts'
import configPromise from './config'
import { pagesSlug } from './shared'
require('isomorphic-fetch')
let client
let serverURL
let page: Page
let media: Media
const schemaJSON = fieldSchemaToJSON(Pages.fields)
describe('Collections - Live Preview', () => {
let client
let serverURL
let testPost: Post
let media: Media
beforeAll(async () => {
const { serverURL: incomingServerURL } = await initPayloadTest({
__dirname,
@@ -35,11 +36,11 @@ describe('Collections - Live Preview', () => {
client = new RESTClient(config, { serverURL, defaultSlug: pagesSlug })
await client.login()
page = await payload.create({
collection: pagesSlug,
testPost = await payload.create({
collection: postsSlug,
data: {
slug: 'home',
title: 'Test Page',
slug: 'post-1',
title: 'Test Post',
},
})
@@ -63,18 +64,20 @@ describe('Collections - Live Preview', () => {
event: {
data: JSON.stringify({
data: {
title: 'Test Page (Change 1)',
title: 'Test Page (Changed)',
},
fieldSchemaJSON: schemaJSON,
type: 'payload-live-preview',
}),
origin: serverURL,
} as MessageEvent,
initialData: page,
initialData: {
title: 'Test Page',
} as Page,
serverURL,
})
expect(handledMessage.title).toEqual('Test Page (Change 1)')
expect(handledMessage.title).toEqual('Test Page (Changed)')
})
it('caches `fieldSchemaJSON`', async () => {
@@ -83,68 +86,89 @@ describe('Collections - Live Preview', () => {
event: {
data: JSON.stringify({
data: {
title: 'Test Page (Change 2)',
title: 'Test Page (Changed)',
},
type: 'payload-live-preview',
}),
origin: serverURL,
} as MessageEvent,
initialData: page,
initialData: {
title: 'Test Page',
} as Page,
serverURL,
})
expect(handledMessage.title).toEqual('Test Page (Change 2)')
expect(handledMessage.title).toEqual('Test Page (Changed)')
})
it('merges data', async () => {
expect(page?.id).toBeDefined()
const initialData: Partial<Page> = {
id: '123',
title: 'Test Page',
}
const mergedData = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: page,
initialData: page,
serverURL,
})
expect(mergedData.id).toEqual(page.id)
})
it('merges strings', async () => {
const mergedData = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...page,
title: 'Test Page (Change 3)',
title: 'Test Page (Merged)',
},
initialData: page,
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(mergedData.title).toEqual('Test Page (Change 3)')
expect(mergedData.id).toEqual(initialData.id)
expect(mergedData._numberOfRequests).toEqual(0)
})
it('— strings - merges data', async () => {
const initialData: Partial<Page> = {
title: 'Test Page',
}
const mergedData = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...initialData,
title: 'Test Page (Changed)',
},
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(mergedData.title).toEqual('Test Page (Changed)')
expect(mergedData._numberOfRequests).toEqual(0)
})
// TODO: this test is not working in Postgres
// This is because of how relationships are handled in `mergeData`
// This test passes in MongoDB, though
it.skip('adds and removes uploads', async () => {
it.skip('— uploads - adds and removes media', async () => {
const initialData: Partial<Page> = {
title: 'Test Page',
}
// Add upload
const mergedData = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...page,
...initialData,
hero: {
type: 'highImpact',
media: media.id,
},
},
initialData: page,
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(mergedData.hero.media).toMatchObject(media)
expect(mergedData._numberOfRequests).toEqual(1)
// Add upload
const mergedDataWithoutUpload = await mergeData({
@@ -161,20 +185,214 @@ describe('Collections - Live Preview', () => {
serverURL,
})
expect(mergedDataWithoutUpload.hero.media).toEqual(null)
expect(mergedDataWithoutUpload.hero.media).toBeFalsy()
})
it('add, reorder, and remove blocks', async () => {
// Add new blocks
it('— relationships - populates all types', async () => {
const initialData: Partial<Page> = {
title: 'Test Page',
}
const merge1 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...page,
...initialData,
relationshipMonoHasOne: testPost.id,
relationshipMonoHasMany: [testPost.id],
relationshipPolyHasMany: [{ value: testPost.id, relationTo: postsSlug }],
},
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(merge1._numberOfRequests).toEqual(3)
expect(merge1.relationshipMonoHasOne).toMatchObject(testPost)
expect(merge1.relationshipMonoHasMany).toMatchObject([testPost])
expect(merge1.relationshipPolyHasMany).toMatchObject([
{ value: testPost, relationTo: postsSlug },
])
// Clear relationships
const merge2 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...merge1,
relationshipMonoHasOne: null,
relationshipMonoHasMany: [],
relationshipPolyHasMany: [],
},
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(merge2._numberOfRequests).toEqual(0)
expect(merge2.relationshipMonoHasOne).toBeFalsy()
expect(merge2.relationshipMonoHasMany).toEqual([])
expect(merge2.relationshipPolyHasMany).toEqual([])
// Now populate the relationships again
const merge3 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...merge2,
relationshipMonoHasOne: testPost.id,
relationshipMonoHasMany: [testPost.id],
relationshipPolyHasMany: [{ value: testPost.id, relationTo: postsSlug }],
},
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(merge3._numberOfRequests).toEqual(3)
expect(merge3.relationshipMonoHasOne).toMatchObject(testPost)
expect(merge3.relationshipMonoHasMany).toMatchObject([testPost])
expect(merge3.relationshipPolyHasMany).toMatchObject([
{ value: testPost, relationTo: postsSlug },
])
})
it('— relationships - populates within arrays', async () => {
const initialData: Partial<Page> = {
title: 'Test Page',
}
const merge1 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...initialData,
arrayOfRelationships: [
{
id: '123',
relationshipWithinArray: testPost.id,
},
],
},
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(merge1._numberOfRequests).toEqual(1)
expect(merge1.arrayOfRelationships).toHaveLength(1)
expect(merge1.arrayOfRelationships).toMatchObject([
{
id: '123',
relationshipWithinArray: testPost,
},
])
// Add a new block before the populated one, then check to see that the relationship is still populated
const merge2 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...merge1,
arrayOfRelationships: [
{
id: '456',
relationshipWithinArray: undefined,
},
{
id: '123',
relationshipWithinArray: testPost.id,
},
],
},
initialData,
serverURL,
returnNumberOfRequests: true,
})
expect(merge2._numberOfRequests).toEqual(1)
expect(merge2.arrayOfRelationships).toHaveLength(2)
expect(merge2.arrayOfRelationships).toMatchObject([
{
id: '456',
},
{
id: '123',
relationshipWithinArray: testPost,
},
])
})
it('— relationships - populates within blocks', async () => {
const block1 = (shallow?: boolean): Extract<Page['layout'][0], { blockType: 'cta' }> => ({
blockType: 'cta',
id: '123',
links: [
{
link: {
label: 'Link 1',
type: 'reference',
reference: {
relationTo: 'posts',
value: shallow ? testPost?.id : testPost,
},
},
},
],
})
const block2: Extract<Page['layout'][0], { blockType: 'content' }> = {
blockType: 'content',
id: '456',
columns: [
{
id: '789',
richText: [
{
type: 'paragraph',
text: 'Column 1',
},
],
},
],
}
const initialData: Partial<Page> = {
title: 'Test Page',
layout: [block1(), block2],
}
// Add a new block before the populated one
// Then check to see that the relationship is still populated
const merge2 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...initialData,
layout: [block2, block1(true)],
},
initialData,
serverURL,
returnNumberOfRequests: true,
})
// Check that the relationship on the first has been removed
// And that the relationship on the second has been populated
expect(merge2.layout[0].links).toBeUndefined()
expect(merge2.layout[1].links[0].link.reference.value).toMatchObject(testPost)
expect(merge2._numberOfRequests).toEqual(1)
})
it('— blocks - adds, reorders, and removes blocks', async () => {
const block1ID = '123'
const block2ID = '456'
const initialData: Partial<Page> = {
title: 'Test Page',
layout: [
{
blockType: 'cta',
id: 'block-1', // use fake ID, this is a new block that is only assigned an ID on the client
id: block1ID,
richText: [
{
type: 'paragraph',
@@ -184,7 +402,7 @@ describe('Collections - Live Preview', () => {
},
{
blockType: 'cta',
id: 'block-2', // use fake ID, this is a new block that is only assigned an ID on the client
id: block2ID,
richText: [
{
type: 'paragraph',
@@ -193,30 +411,18 @@ describe('Collections - Live Preview', () => {
],
},
],
},
initialData: page,
serverURL,
})
}
// Check that the blocks have been merged and are in the correct order
expect(merge1.layout).toHaveLength(2)
const block1 = merge1.layout[0]
expect(block1.id).toEqual('block-1')
expect(block1.richText[0].text).toEqual('Block 1 (Position 1)')
const block2 = merge1.layout[1]
expect(block2.id).toEqual('block-2')
expect(block2.richText[0].text).toEqual('Block 2 (Position 2)')
// Reorder the blocks using the same IDs from the previous merge
const merge2 = await mergeData({
// Reorder the blocks
const merge1 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...merge1,
...initialData,
layout: [
{
id: block2.id,
blockType: 'content',
blockType: 'cta',
id: block2ID,
richText: [
{
type: 'paragraph',
@@ -225,8 +431,8 @@ describe('Collections - Live Preview', () => {
],
},
{
id: block1.id,
blockType: 'content',
blockType: 'cta',
id: block1ID,
richText: [
{
type: 'paragraph',
@@ -236,27 +442,29 @@ describe('Collections - Live Preview', () => {
},
],
},
initialData: merge1,
initialData,
serverURL,
returnNumberOfRequests: true,
})
// Check that the blocks have been reordered
expect(merge2.layout).toHaveLength(2)
expect(merge2.layout[0].id).toEqual(block2.id)
expect(merge2.layout[1].id).toEqual(block1.id)
expect(merge2.layout[0].richText[0].text).toEqual('Block 2 (Position 1)')
expect(merge2.layout[1].richText[0].text).toEqual('Block 1 (Position 2)')
expect(merge1.layout).toHaveLength(2)
expect(merge1.layout[0].id).toEqual(block2ID)
expect(merge1.layout[1].id).toEqual(block1ID)
expect(merge1.layout[0].richText[0].text).toEqual('Block 2 (Position 1)')
expect(merge1.layout[1].richText[0].text).toEqual('Block 1 (Position 2)')
expect(merge1._numberOfRequests).toEqual(0)
// Remove a block
const merge3 = await mergeData({
const merge2 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...merge2,
...initialData,
layout: [
{
id: block2.id,
blockType: 'content',
blockType: 'cta',
id: block2ID,
richText: [
{
type: 'paragraph',
@@ -266,28 +474,32 @@ describe('Collections - Live Preview', () => {
},
],
},
initialData: merge2,
initialData,
serverURL,
returnNumberOfRequests: true,
})
// Check that the block has been removed
expect(merge3.layout).toHaveLength(1)
expect(merge3.layout[0].id).toEqual(block2.id)
expect(merge3.layout[0].richText[0].text).toEqual('Block 2 (Position 1)')
expect(merge2.layout).toHaveLength(1)
expect(merge2.layout[0].id).toEqual(block2ID)
expect(merge2.layout[0].richText[0].text).toEqual('Block 2 (Position 1)')
expect(merge2._numberOfRequests).toEqual(0)
// Remove the last block to ensure that all blocks can be cleared
const merge4 = await mergeData({
const merge3 = await mergeData({
depth: 1,
fieldSchema: schemaJSON,
incomingData: {
...merge3,
...initialData,
layout: [],
},
initialData: merge3,
initialData,
serverURL,
returnNumberOfRequests: true,
})
// Check that the block has been removed
expect(merge4.layout).toHaveLength(0)
expect(merge3.layout).toHaveLength(0)
expect(merge3._numberOfRequests).toEqual(0)
})
})

View File

@@ -19,7 +19,7 @@ const blockComponents = {
}
export const Blocks: React.FC<{
blocks: (Page['layout'][0] | RelatedPostsProps)[]
blocks?: (Page['layout'][0] | RelatedPostsProps)[]
disableTopPadding?: boolean
}> = (props) => {
const { disableTopPadding, blocks } = props

View File

@@ -38,8 +38,8 @@ export const Image: React.FC<MediaProps> = (props) => {
alt: altFromResource,
} = resource
width = fullWidth
height = fullHeight
width = fullWidth || undefined
height = fullHeight || undefined
alt = altFromResource
const filename = fullFilename

View File

@@ -1,32 +1,20 @@
import React, { Fragment } from 'react'
import { Page } from '../../../payload/payload-types'
import { Page } from '../../../payload-types'
import { Gutter } from '../../_components/Gutter'
import { CMSLink } from '../../_components/Link'
import { Media } from '../../_components/Media'
import RichText from '../../_components/RichText'
import classes from './index.module.scss'
export const HighImpactHero: React.FC<Page['hero']> = ({ richText, media, links }) => {
export const HighImpactHero: React.FC<Page['hero']> = ({ richText, media }) => {
return (
<Gutter className={classes.hero}>
<div className={classes.content}>
<RichText content={richText} />
{Array.isArray(links) && links.length > 0 && (
<ul className={classes.links}>
{links.map(({ link }, i) => {
return (
<li key={i}>
<CMSLink {...link} />
</li>
)
})}
</ul>
)}
</div>
<div className={classes.media}>
{typeof media === 'object' && (
{typeof media === 'object' && media !== null && (
<Fragment>
<Media
resource={media}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { Page } from '../../../payload/payload-types'
import { Page } from '../../../payload-types'
import { Gutter } from '../../_components/Gutter'
import RichText from '../../_components/RichText'
import { VerticalPadding } from '../../_components/VerticalPadding'

View File

@@ -26,13 +26,13 @@ export interface User {
updatedAt: string
createdAt: string
email: string
resetPasswordToken?: string
resetPasswordExpiration?: string
salt?: string
hash?: string
loginAttempts?: number
lockUntil?: string
password: string
resetPasswordToken?: string | null
resetPasswordExpiration?: string | null
salt?: string | null
hash?: string | null
loginAttempts?: number | null
lockUntil?: string | null
password: string | null
}
export interface Page {
id: string
@@ -40,126 +40,150 @@ export interface Page {
title: string
hero: {
type: 'none' | 'highImpact' | 'lowImpact'
richText?: {
[k: string]: unknown
}[]
media: string | Media
}
layout?: (
richText?:
| {
invertBackground?: boolean
richText?: {
[k: string]: unknown
}[]
links?: {
| null
media?: string | Media | null
}
layout?:
| (
| {
invertBackground?: boolean | null
richText?:
| {
[k: string]: unknown
}[]
| null
links?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?: {
relationTo: 'pages'
value: string | Page
}
url: string
} | null
url?: string | null
label: string
appearance?: 'primary' | 'secondary'
appearance?: ('primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'cta'
}
| {
invertBackground?: boolean
columns?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full'
richText?: {
invertBackground?: boolean | null
columns?:
| {
size?: ('oneThird' | 'half' | 'twoThirds' | 'full') | null
richText?:
| {
[k: string]: unknown
}[]
enableLink?: boolean
| null
enableLink?: boolean | null
link?: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?: {
relationTo: 'pages'
value: string | Page
}
url: string
} | null
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'content'
}
| {
invertBackground?: boolean
position?: 'default' | 'fullscreen'
invertBackground?: boolean | null
position?: ('default' | 'fullscreen') | null
media: string | Media
id?: string
blockName?: string
id?: string | null
blockName?: string | null
blockType: 'mediaBlock'
}
| {
introContent?: {
introContent?:
| {
[k: string]: unknown
}[]
populateBy?: 'collection' | 'selection'
relationTo?: 'posts'
categories?: string[] | Category[]
limit?: number
| null
populateBy?: ('collection' | 'selection') | null
relationTo?: 'posts' | null
categories?: (string | Category)[] | null
limit?: number | null
selectedDocs?:
| {
relationTo: 'posts'
value: string
}[]
| {
relationTo: 'posts'
value: Post
value: string | Post
}[]
| null
populatedDocs?:
| {
relationTo: 'posts'
value: string
value: string | Post
}[]
| {
relationTo: 'posts'
value: Post
}[]
populatedDocsTotal?: number
id?: string
blockName?: string
| null
populatedDocsTotal?: number | null
id?: string | null
blockName?: string | null
blockType: 'archive'
}
)[]
| null
meta?: {
title?: string
description?: string
image?: string | Media
title?: string | null
description?: string | null
image?: string | Media | null
}
relationshipPolyHasMany?:
| {
relationTo: 'posts'
value: string | Post
}[]
| null
relationshipMonoHasMany?: (string | Post)[] | null
relationshipMonoHasOne?: (string | null) | Post
arrayOfRelationships?:
| {
relationshipWithinArray?: (string | null) | Post
id?: string | null
}[]
| null
updatedAt: string
createdAt: string
}
export interface Media {
id: string
alt: string
caption?: {
caption?:
| {
[k: string]: unknown
}[]
| null
updatedAt: string
createdAt: string
url?: string
filename?: string
mimeType?: string
filesize?: number
width?: number
height?: number
url?: string | null
filename?: string | null
mimeType?: string | null
filesize?: number | null
width?: number | null
height?: number | null
}
export interface Category {
id: string
title?: string
title?: string | null
updatedAt: string
createdAt: string
}
@@ -169,105 +193,113 @@ export interface Post {
title: string
hero: {
type: 'none' | 'highImpact' | 'lowImpact'
richText?: {
[k: string]: unknown
}[]
media: string | Media
}
layout?: (
richText?:
| {
invertBackground?: boolean
richText?: {
[k: string]: unknown
}[]
links?: {
| null
media?: string | Media | null
}
layout?:
| (
| {
invertBackground?: boolean | null
richText?:
| {
[k: string]: unknown
}[]
| null
links?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?: {
relationTo: 'pages'
value: string | Page
}
url: string
} | null
url?: string | null
label: string
appearance?: 'primary' | 'secondary'
appearance?: ('primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'cta'
}
| {
invertBackground?: boolean
columns?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full'
richText?: {
invertBackground?: boolean | null
columns?:
| {
size?: ('oneThird' | 'half' | 'twoThirds' | 'full') | null
richText?:
| {
[k: string]: unknown
}[]
enableLink?: boolean
| null
enableLink?: boolean | null
link?: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?: {
relationTo: 'pages'
value: string | Page
}
url: string
} | null
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'content'
}
| {
invertBackground?: boolean
position?: 'default' | 'fullscreen'
invertBackground?: boolean | null
position?: ('default' | 'fullscreen') | null
media: string | Media
id?: string
blockName?: string
id?: string | null
blockName?: string | null
blockType: 'mediaBlock'
}
| {
introContent?: {
introContent?:
| {
[k: string]: unknown
}[]
populateBy?: 'collection' | 'selection'
relationTo?: 'posts'
categories?: string[] | Category[]
limit?: number
| null
populateBy?: ('collection' | 'selection') | null
relationTo?: 'posts' | null
categories?: (string | Category)[] | null
limit?: number | null
selectedDocs?:
| {
relationTo: 'posts'
value: string
}[]
| {
relationTo: 'posts'
value: Post
value: string | Post
}[]
| null
populatedDocs?:
| {
relationTo: 'posts'
value: string
value: string | Post
}[]
| {
relationTo: 'posts'
value: Post
}[]
populatedDocsTotal?: number
id?: string
blockName?: string
| null
populatedDocsTotal?: number | null
id?: string | null
blockName?: string | null
blockType: 'archive'
}
)[]
relatedPosts?: string[] | Post[]
| null
relatedPosts?: (string | Post)[] | null
meta?: {
title?: string
description?: string
image?: string | Media
title?: string | null
description?: string | null
image?: string | Media | null
}
updatedAt: string
createdAt: string
@@ -278,7 +310,7 @@ export interface PayloadPreference {
relationTo: 'users'
value: string | User
}
key?: string
key?: string | null
value?:
| {
[k: string]: unknown
@@ -293,48 +325,52 @@ export interface PayloadPreference {
}
export interface PayloadMigration {
id: string
name?: string
batch?: number
name?: string | null
batch?: number | null
updatedAt: string
createdAt: string
}
export interface Header {
id: string
navItems?: {
navItems?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?: {
relationTo: 'pages'
value: string | Page
}
url: string
} | null
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
updatedAt?: string
createdAt?: string
| null
updatedAt?: string | null
createdAt?: string | null
}
export interface Footer {
id: string
navItems?: {
navItems?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?: {
relationTo: 'pages'
value: string | Page
}
url: string
} | null
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
updatedAt?: string
createdAt?: string
| null
updatedAt?: string | null
createdAt?: string | null
}
declare module 'payload' {

View File

@@ -26,13 +26,13 @@ export interface User {
updatedAt: string
createdAt: string
email: string
resetPasswordToken?: string
resetPasswordExpiration?: string
salt?: string
hash?: string
loginAttempts?: number
lockUntil?: string
password: string
resetPasswordToken?: string | null
resetPasswordExpiration?: string | null
salt?: string | null
hash?: string | null
loginAttempts?: number | null
lockUntil?: string | null
password: string | null
}
export interface Page {
id: string
@@ -40,128 +40,156 @@ export interface Page {
title: string
hero: {
type: 'none' | 'highImpact' | 'lowImpact'
richText?: {
[k: string]: unknown
}[]
media: string | Media
}
layout?: (
richText?:
| {
invertBackground?: boolean
richText?: {
[k: string]: unknown
}[]
links?: {
| null
media?: string | Media | null
}
layout?:
| (
| {
invertBackground?: boolean | null
richText?:
| {
[k: string]: unknown
}[]
| null
links?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?:
| ({
relationTo: 'posts'
value: string | Post
} | null)
| ({
relationTo: 'pages'
value: string | Page
}
url: string
} | null)
url?: string | null
label: string
appearance?: 'primary' | 'secondary'
appearance?: ('primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'cta'
}
| {
invertBackground?: boolean
columns?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full'
richText?: {
invertBackground?: boolean | null
columns?:
| {
size?: ('oneThird' | 'half' | 'twoThirds' | 'full') | null
richText?:
| {
[k: string]: unknown
}[]
enableLink?: boolean
| null
enableLink?: boolean | null
link?: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?:
| ({
relationTo: 'posts'
value: string | Post
} | null)
| ({
relationTo: 'pages'
value: string | Page
}
url: string
} | null)
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'content'
}
| {
invertBackground?: boolean
position?: 'default' | 'fullscreen'
invertBackground?: boolean | null
position?: ('default' | 'fullscreen') | null
media: string | Media
id?: string
blockName?: string
id?: string | null
blockName?: string | null
blockType: 'mediaBlock'
}
| {
introContent?: {
introContent?:
| {
[k: string]: unknown
}[]
populateBy?: 'collection' | 'selection'
relationTo?: 'posts'
categories?: string[] | Category[]
limit?: number
| null
populateBy?: ('collection' | 'selection') | null
relationTo?: 'posts' | null
categories?: (string | Category)[] | null
limit?: number | null
selectedDocs?:
| {
relationTo: 'posts'
value: string
}[]
| {
relationTo: 'posts'
value: Post
value: string | Post
}[]
| null
populatedDocs?:
| {
relationTo: 'posts'
value: string
value: string | Post
}[]
| {
relationTo: 'posts'
value: Post
}[]
populatedDocsTotal?: number
id?: string
blockName?: string
| null
populatedDocsTotal?: number | null
id?: string | null
blockName?: string | null
blockType: 'archive'
}
)[]
| null
meta?: {
title?: string
description?: string
image?: string | Media
title?: string | null
description?: string | null
image?: string | Media | null
}
relationshipPolyHasMany?:
| {
relationTo: 'posts'
value: string | Post
}[]
| null
relationshipMonoHasMany?: (string | Post)[] | null
relationshipMonoHasOne?: (string | null) | Post
arrayOfRelationships?:
| {
relationshipWithinArray?: (string | null) | Post
id?: string | null
}[]
| null
updatedAt: string
createdAt: string
}
export interface Media {
id: string
alt: string
caption?: {
caption?:
| {
[k: string]: unknown
}[]
| null
updatedAt: string
createdAt: string
url?: string
filename?: string
mimeType?: string
filesize?: number
width?: number
height?: number
}
export interface Category {
id: string
title?: string
updatedAt: string
createdAt: string
url?: string | null
filename?: string | null
mimeType?: string | null
filesize?: number | null
width?: number | null
height?: number | null
}
export interface Post {
id: string
@@ -169,116 +197,140 @@ export interface Post {
title: string
hero: {
type: 'none' | 'highImpact' | 'lowImpact'
richText?: {
[k: string]: unknown
}[]
media: string | Media
}
layout?: (
richText?:
| {
invertBackground?: boolean
richText?: {
[k: string]: unknown
}[]
links?: {
| null
media?: string | Media | null
}
layout?:
| (
| {
invertBackground?: boolean | null
richText?:
| {
[k: string]: unknown
}[]
| null
links?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?:
| ({
relationTo: 'posts'
value: string | Post
} | null)
| ({
relationTo: 'pages'
value: string | Page
}
url: string
} | null)
url?: string | null
label: string
appearance?: 'primary' | 'secondary'
appearance?: ('primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'cta'
}
| {
invertBackground?: boolean
columns?: {
size?: 'oneThird' | 'half' | 'twoThirds' | 'full'
richText?: {
invertBackground?: boolean | null
columns?:
| {
size?: ('oneThird' | 'half' | 'twoThirds' | 'full') | null
richText?:
| {
[k: string]: unknown
}[]
enableLink?: boolean
| null
enableLink?: boolean | null
link?: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?:
| ({
relationTo: 'posts'
value: string | Post
} | null)
| ({
relationTo: 'pages'
value: string | Page
}
url: string
} | null)
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
id?: string
blockName?: string
| null
id?: string | null
blockName?: string | null
blockType: 'content'
}
| {
invertBackground?: boolean
position?: 'default' | 'fullscreen'
invertBackground?: boolean | null
position?: ('default' | 'fullscreen') | null
media: string | Media
id?: string
blockName?: string
id?: string | null
blockName?: string | null
blockType: 'mediaBlock'
}
| {
introContent?: {
introContent?:
| {
[k: string]: unknown
}[]
populateBy?: 'collection' | 'selection'
relationTo?: 'posts'
categories?: string[] | Category[]
limit?: number
| null
populateBy?: ('collection' | 'selection') | null
relationTo?: 'posts' | null
categories?: (string | Category)[] | null
limit?: number | null
selectedDocs?:
| {
relationTo: 'posts'
value: string
}[]
| {
relationTo: 'posts'
value: Post
value: string | Post
}[]
| null
populatedDocs?:
| {
relationTo: 'posts'
value: string
value: string | Post
}[]
| {
relationTo: 'posts'
value: Post
}[]
populatedDocsTotal?: number
id?: string
blockName?: string
| null
populatedDocsTotal?: number | null
id?: string | null
blockName?: string | null
blockType: 'archive'
}
)[]
relatedPosts?: string[] | Post[]
| null
relatedPosts?: (string | Post)[] | null
meta?: {
title?: string
description?: string
image?: string | Media
title?: string | null
description?: string | null
image?: string | Media | null
}
updatedAt: string
createdAt: string
}
export interface Category {
id: string
title?: string | null
updatedAt: string
createdAt: string
}
export interface PayloadPreference {
id: string
user: {
relationTo: 'users'
value: string | User
}
key?: string
key?: string | null
value?:
| {
[k: string]: unknown
@@ -293,48 +345,62 @@ export interface PayloadPreference {
}
export interface PayloadMigration {
id: string
name?: string
batch?: number
name?: string | null
batch?: number | null
updatedAt: string
createdAt: string
}
export interface Header {
id: string
navItems?: {
navItems?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?:
| ({
relationTo: 'posts'
value: string | Post
} | null)
| ({
relationTo: 'pages'
value: string | Page
}
url: string
} | null)
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
updatedAt?: string
createdAt?: string
| null
updatedAt?: string | null
createdAt?: string | null
}
export interface Footer {
id: string
navItems?: {
navItems?:
| {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
type?: ('reference' | 'custom') | null
newTab?: boolean | null
reference?:
| ({
relationTo: 'posts'
value: string | Post
} | null)
| ({
relationTo: 'pages'
value: string | Page
}
url: string
} | null)
url?: string | null
label: string
appearance?: 'default' | 'primary' | 'secondary'
appearance?: ('default' | 'primary' | 'secondary') | null
}
id?: string
id?: string | null
}[]
updatedAt?: string
createdAt?: string
| null
updatedAt?: string | null
createdAt?: string | null
}
declare module 'payload' {

View File

@@ -1,9 +1,8 @@
import type { Page } from '../payload-types'
export const home: Page = {
export const home: Omit<Page, 'createdAt' | 'id' | 'updatedAt'> = {
slug: 'home',
title: 'Home',
id: '',
meta: {
description: 'This is an example of live preview on a page.',
},
@@ -94,4 +93,12 @@ export const home: Page = {
],
},
],
relationshipMonoHasMany: ['{{POST_1_ID}}'],
relationshipMonoHasOne: '{{POST_1_ID}}',
relationshipPolyHasMany: [{ relationTo: 'posts', value: '{{POST_1_ID}}' }],
arrayOfRelationships: [
{
relationshipWithinArray: '{{POST_1_ID}}',
},
],
}

View File

@@ -1,5 +1,5 @@
import type { Post } from '../payload-types'
export const post1: Partial<Post> = {
export const post1: Omit<Post, 'createdAt' | 'id' | 'updatedAt'> = {
title: 'Post 1',
slug: 'post-1',
meta: {

View File

@@ -1,6 +1,6 @@
import type { Post } from '../payload-types'
export const post2: Partial<Post> = {
export const post2: Omit<Post, 'createdAt' | 'id' | 'updatedAt'> = {
title: 'Post 2',
slug: 'post-2',
meta: {

View File

@@ -1,6 +1,6 @@
import type { Post } from '../payload-types'
export const post3: Partial<Post> = {
export const post3: Omit<Post, 'createdAt' | 'id' | 'updatedAt'> = {
title: 'Post 3',
slug: 'post-3',
meta: {