= ({
serverURL,
} = useConfig()
const { docConfig, getVersions, versions } = useDocumentInfo()
+ const { reportUpdate } = useDocumentEvents()
const versionsConfig = docConfig?.versions
const [fields] = useAllFormFields()
@@ -74,14 +76,17 @@ export const Autosave: React.FC
= ({
let url: string
let method: string
+ let entitySlug: string
if (collection && id) {
- url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`
+ entitySlug = collection.slug
+ url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'PATCH'
}
if (globalDoc) {
- url = `${serverURL}${api}/globals/${globalDoc.slug}?draft=true&autosave=true&locale=${localeRef.current}`
+ entitySlug = globalDoc.slug
+ url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'POST'
}
@@ -103,7 +108,13 @@ export const Autosave: React.FC = ({
})
if (res.status === 200) {
- setLastSaved(new Date().getTime())
+ const newDate = new Date()
+ setLastSaved(newDate.getTime())
+ reportUpdate({
+ id,
+ entitySlug,
+ updatedAt: newDate.toISOString(),
+ })
void getVersions()
}
}
@@ -115,7 +126,18 @@ export const Autosave: React.FC = ({
}
void autosave()
- }, [i18n, debouncedFields, modified, serverURL, api, collection, globalDoc, id, getVersions])
+ }, [
+ i18n,
+ debouncedFields,
+ modified,
+ serverURL,
+ api,
+ collection,
+ globalDoc,
+ reportUpdate,
+ id,
+ getVersions,
+ ])
useEffect(() => {
if (versions?.docs?.[0]) {
diff --git a/test/live-preview/app/(payload)/api/[...slug]/route.ts b/test/live-preview/app/(payload)/api/[...slug]/route.ts
index eacae2961..52caec96a 100644
--- a/test/live-preview/app/(payload)/api/[...slug]/route.ts
+++ b/test/live-preview/app/(payload)/api/[...slug]/route.ts
@@ -1,9 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
-import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes/index.js'
+import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
+export const OPTIONS = REST_OPTIONS(config)
diff --git a/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx b/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx
index 8bc88906f..ad17f3278 100644
--- a/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx
+++ b/test/live-preview/app/live-preview/(pages)/[slug]/page.client.tsx
@@ -5,6 +5,7 @@ import React from 'react'
import type { Page as PageType } from '../../../../payload-types.js'
+import { renderedPageTitleID } from '../../../../shared.js'
import { PAYLOAD_SERVER_URL } from '../../_api/serverURL.js'
import { Blocks } from '../../_components/Blocks/index.js'
import { Gutter } from '../../_components/Gutter/index.js'
@@ -22,7 +23,7 @@ export const PageClient: React.FC<{
return (
- {data.title}
+ {data.title}
{
+ const router = useRouter()
+ return router.refresh()} serverURL={PAYLOAD_SERVER_URL} />
+}
diff --git a/test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/page.tsx b/test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/page.tsx
new file mode 100644
index 000000000..f5fbbfc93
--- /dev/null
+++ b/test/live-preview/app/live-preview/(pages)/posts-ssr/[slug]/page.tsx
@@ -0,0 +1,94 @@
+/* eslint-disable no-restricted-exports */
+import { Gutter } from '@payloadcms/ui/elements/Gutter'
+import { notFound } from 'next/navigation.js'
+import React, { Fragment } from 'react'
+
+import type { Post } from '../../../../../payload-types.js'
+
+import { ssrPostsSlug } from '../../../../../shared.js'
+import { renderedPageTitleID } from '../../../../../shared.js'
+import { fetchDoc } from '../../../_api/fetchDoc.js'
+import { fetchDocs } from '../../../_api/fetchDocs.js'
+import { Blocks } from '../../../_components/Blocks/index.js'
+import { PostHero } from '../../../_heros/PostHero/index.js'
+import { RefreshRouteOnSave } from './RefreshRouteOnSave.js'
+
+export default async function SSRPost({ params: { slug = '' } }) {
+ let data: Post | null = null
+
+ try {
+ data = await fetchDoc({
+ slug,
+ collection: ssrPostsSlug,
+ draft: true,
+ })
+ } catch (error) {
+ console.error(error) // eslint-disable-line no-console
+ }
+
+ if (!data) {
+ notFound()
+ }
+
+ return (
+
+
+
+ {data.title}
+
+
+
+
+
+ )
+}
+
+export async function generateStaticParams() {
+ process.env.PAYLOAD_DROP_DATABASE = 'false'
+ try {
+ const ssrPosts = await fetchDocs(ssrPostsSlug)
+ return ssrPosts?.map(({ slug }) => slug)
+ } catch (error) {
+ return []
+ }
+}
diff --git a/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx b/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx
index 0d8182945..72fa036ca 100644
--- a/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx
+++ b/test/live-preview/app/live-preview/(pages)/posts/[slug]/page.client.tsx
@@ -1,10 +1,12 @@
'use client'
import { useLivePreview } from '@payloadcms/live-preview-react'
+import { Gutter } from '@payloadcms/ui/elements/Gutter'
import React from 'react'
import type { Post as PostType } from '../../../../../payload-types.js'
+import { renderedPageTitleID } from '../../../../../shared.js'
import { PAYLOAD_SERVER_URL } from '../../../_api/serverURL.js'
import { Blocks } from '../../../_components/Blocks/index.js'
import { PostHero } from '../../../_heros/PostHero/index.js'
@@ -20,6 +22,9 @@ export const PostClient: React.FC<{
return (
+
+ {data.title}
+
({
slug,
- collection: 'posts',
+ collection: postsSlug,
})
} catch (error) {
console.error(error) // eslint-disable-line no-console
@@ -29,8 +31,8 @@ export default async function Post({ params: { slug = '' } }) {
export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
- const posts = await fetchDocs('posts')
- return posts?.map(({ slug }) => slug)
+ const ssrPosts = await fetchDocs(postsSlug)
+ return ssrPosts?.map(({ slug }) => slug)
} catch (error) {
return []
}
diff --git a/test/live-preview/app/live-preview/_api/fetchDoc.ts b/test/live-preview/app/live-preview/_api/fetchDoc.ts
index 829b3fca6..8f00ab1ea 100644
--- a/test/live-preview/app/live-preview/_api/fetchDoc.ts
+++ b/test/live-preview/app/live-preview/_api/fetchDoc.ts
@@ -6,10 +6,11 @@ import { getPayloadHMR } from '@payloadcms/next/utilities/getPayloadHMR.js'
export const fetchDoc = async (args: {
collection: string
depth?: number
+ draft?: boolean
slug?: string
}): Promise => {
const payload = await getPayloadHMR({ config })
- const { slug, collection, depth = 2 } = args || {}
+ const { slug, collection, depth = 2, draft } = args || {}
const where: Where = {}
@@ -24,6 +25,7 @@ export const fetchDoc = async (args: {
collection,
depth,
where,
+ draft,
})
if (docs[0]) return docs[0] as T
diff --git a/test/live-preview/collections/PostsSSR.ts b/test/live-preview/collections/PostsSSR.ts
new file mode 100644
index 000000000..f1a1fdebf
--- /dev/null
+++ b/test/live-preview/collections/PostsSSR.ts
@@ -0,0 +1,107 @@
+import type { CollectionConfig } from 'payload/types'
+
+import { Archive } from '../blocks/ArchiveBlock/index.js'
+import { CallToAction } from '../blocks/CallToAction/index.js'
+import { Content } from '../blocks/Content/index.js'
+import { MediaBlock } from '../blocks/MediaBlock/index.js'
+import { hero } from '../fields/hero.js'
+import { ssrPostsSlug, tenantsSlug } from '../shared.js'
+
+export const PostsSSR: CollectionConfig = {
+ slug: ssrPostsSlug,
+ labels: {
+ singular: 'SSR Post',
+ plural: 'SSR Posts',
+ },
+ access: {
+ read: () => true,
+ create: () => true,
+ update: () => true,
+ delete: () => true,
+ },
+ versions: {
+ drafts: {
+ autosave: {
+ interval: 10,
+ },
+ },
+ },
+ admin: {
+ useAsTitle: 'title',
+ defaultColumns: ['id', 'title', 'slug', 'createdAt'],
+ },
+ fields: [
+ {
+ name: 'slug',
+ type: 'text',
+ required: true,
+ admin: {
+ position: 'sidebar',
+ },
+ },
+ {
+ name: 'tenant',
+ type: 'relationship',
+ relationTo: tenantsSlug,
+ admin: {
+ position: 'sidebar',
+ },
+ },
+ {
+ name: 'title',
+ type: 'text',
+ required: true,
+ },
+ {
+ type: 'tabs',
+ tabs: [
+ {
+ label: 'Hero',
+ fields: [hero],
+ },
+ {
+ label: 'Content',
+ fields: [
+ {
+ name: 'layout',
+ type: 'blocks',
+ blocks: [CallToAction, Content, MediaBlock, Archive],
+ },
+ {
+ name: 'relatedPosts',
+ type: 'relationship',
+ relationTo: 'posts',
+ hasMany: true,
+ filterOptions: ({ id }) => {
+ return {
+ id: {
+ not_in: [id],
+ },
+ }
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'meta',
+ type: 'group',
+ fields: [
+ {
+ name: 'title',
+ type: 'text',
+ },
+ {
+ name: 'description',
+ type: 'textarea',
+ },
+ {
+ name: 'image',
+ type: 'upload',
+ relationTo: 'media',
+ },
+ ],
+ },
+ ],
+}
diff --git a/test/live-preview/config.ts b/test/live-preview/config.ts
index 139f8bb41..e4396d010 100644
--- a/test/live-preview/config.ts
+++ b/test/live-preview/config.ts
@@ -2,13 +2,14 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import Categories from './collections/Categories.js'
import { Media } from './collections/Media.js'
import { Pages } from './collections/Pages.js'
-import { Posts } from './collections/Posts.js'
+import { Posts, postsSlug } from './collections/Posts.js'
+import { PostsSSR } from './collections/PostsSSR.js'
import { Tenants } from './collections/Tenants.js'
import { Users } from './collections/Users.js'
import { Footer } from './globals/Footer.js'
import { Header } from './globals/Header.js'
import { seed } from './seed/index.js'
-import { mobileBreakpoint } from './shared.js'
+import { mobileBreakpoint, pagesSlug, ssrPostsSlug } from './shared.js'
import { formatLivePreviewURL } from './utilities/formatLivePreviewURL.js'
export default buildConfigWithDefaults({
@@ -18,13 +19,13 @@ export default buildConfigWithDefaults({
// The Live Preview config cascades from the top down, properties are inherited from here
url: formatLivePreviewURL,
breakpoints: [mobileBreakpoint],
- collections: ['pages', 'posts'],
+ collections: [pagesSlug, postsSlug, ssrPostsSlug],
globals: ['header', 'footer'],
},
},
cors: ['http://localhost:3000', 'http://localhost:3001'],
csrf: ['http://localhost:3000', 'http://localhost:3001'],
- collections: [Users, Pages, Posts, Tenants, Categories, Media],
+ collections: [Users, Pages, Posts, PostsSSR, Tenants, Categories, Media],
globals: [Header, Footer],
onInit: seed,
})
diff --git a/test/live-preview/e2e.spec.ts b/test/live-preview/e2e.spec.ts
index edd112703..43864d812 100644
--- a/test/live-preview/e2e.spec.ts
+++ b/test/live-preview/e2e.spec.ts
@@ -14,7 +14,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
-import { mobileBreakpoint } from './shared.js'
+import { mobileBreakpoint, pagesSlug, renderedPageTitleID, ssrPostsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -23,16 +23,18 @@ const { beforeAll, describe } = test
describe('Live Preview', () => {
let page: Page
let serverURL: string
- let url: AdminUrlUtil
- const goToDoc = async (page: Page) => {
- await page.goto(url.list)
- await page.waitForURL(url.list)
+ let pagesURLUtil: AdminUrlUtil
+ let ssrPostsURLUtil: AdminUrlUtil
+
+ const goToDoc = async (page: Page, urlUtil: AdminUrlUtil) => {
+ await page.goto(urlUtil.list)
+ await page.waitForURL(urlUtil.list)
await navigateToListCellLink(page)
}
- const goToCollectionPreview = async (page: Page): Promise => {
- await goToDoc(page)
+ const goToCollectionPreview = async (page: Page, urlUtil: AdminUrlUtil): Promise => {
+ await goToDoc(page, urlUtil)
await page.goto(`${page.url()}/preview`)
await page.waitForURL(`**/preview`)
}
@@ -47,7 +49,10 @@ describe('Live Preview', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ serverURL } = await initPayloadE2ENoConfig({ dirname }))
- url = new AdminUrlUtil(serverURL, 'pages')
+
+ pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug)
+ ssrPostsURLUtil = new AdminUrlUtil(serverURL, ssrPostsSlug)
+
const context = await browser.newContext()
page = await context.newPage()
@@ -57,7 +62,7 @@ describe('Live Preview', () => {
})
test('collection — has tab', async () => {
- await goToDoc(page)
+ await goToDoc(page, pagesURLUtil)
const livePreviewTab = page.locator('.doc-tab', {
hasText: exactText('Live Preview'),
@@ -75,46 +80,82 @@ describe('Live Preview', () => {
})
test('collection — has route', async () => {
- await goToDoc(page)
- await goToCollectionPreview(page)
-
+ await goToCollectionPreview(page, pagesURLUtil)
await expect(page.locator('.live-preview')).toBeVisible()
})
test('collection — renders iframe', async () => {
- await goToCollectionPreview(page)
+ await goToCollectionPreview(page, pagesURLUtil)
const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible()
})
- test('collection — can edit fields and can preview updated value', async () => {
- await goToCollectionPreview(page)
- const titleValue = 'Title 1'
- const field = page.locator('#field-title')
+ test('collection — re-renders iframe client-side when form state changes', async () => {
+ await goToCollectionPreview(page, pagesURLUtil)
+
+ const titleField = page.locator('#field-title')
const frame = page.frameLocator('iframe.live-preview-iframe').first()
- await expect(field).toBeVisible()
+ await expect(titleField).toBeVisible()
- // Forces the test to wait for the nextjs route to render before we try editing a field
- await expect(() => expect(frame.locator('#page-title')).toBeVisible()).toPass({
+ const renderedPageTitleLocator = `#${renderedPageTitleID}`
+
+ // Forces the test to wait for the Next.js route to render before we try editing a field
+ await expect(() => expect(frame.locator(renderedPageTitleLocator)).toBeVisible()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
- await field.fill(titleValue)
+ await expect(frame.locator(renderedPageTitleLocator)).toHaveText('Home')
- await expect(() => expect(frame.locator('#page-title')).toHaveText(titleValue)).toPass({
+ const newTitleValue = 'Home (Edited)'
+
+ await titleField.fill(newTitleValue)
+
+ await expect(() =>
+ expect(frame.locator(renderedPageTitleLocator)).toHaveText(newTitleValue),
+ ).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await saveDocAndAssert(page)
})
- test('collection — should show live-preview view level action in live-preview view', async () => {
- await goToCollectionPreview(page)
+ test('collection — re-render iframe server-side when autosave is made', async () => {
+ await goToCollectionPreview(page, ssrPostsURLUtil)
+
+ const titleField = page.locator('#field-title')
+ const frame = page.frameLocator('iframe.live-preview-iframe').first()
+
+ await expect(titleField).toBeVisible()
+
+ const renderedPageTitleLocator = `#${renderedPageTitleID}`
+
+ // Forces the test to wait for the Next.js route to render before we try editing a field
+ await expect(() => expect(frame.locator(renderedPageTitleLocator)).toBeVisible()).toPass({
+ timeout: POLL_TOPASS_TIMEOUT,
+ })
+
+ await expect(frame.locator(renderedPageTitleLocator)).toHaveText('SSR Post 1')
+
+ const newTitleValue = 'SSR Post 1 (Edited)'
+
+ await titleField.fill(newTitleValue)
+
+ await expect(() =>
+ expect(frame.locator(renderedPageTitleLocator)).toHaveText(newTitleValue),
+ ).toPass({
+ timeout: POLL_TOPASS_TIMEOUT,
+ })
+
+ await saveDocAndAssert(page)
+ })
+
+ test('collection — should show live-preview view-level action in live-preview view', async () => {
+ await goToCollectionPreview(page, pagesURLUtil)
await expect(page.locator('.app-header .collection-live-preview-button')).toHaveCount(1)
})
- test('global — should show live-preview view level action in live-preview view', async () => {
+ test('global — should show live-preview view-level action in live-preview view', async () => {
await goToGlobalPreview(page, 'footer')
await expect(page.locator('.app-header .global-live-preview-button')).toHaveCount(1)
})
@@ -164,13 +205,13 @@ describe('Live Preview', () => {
})
test('properly measures iframe and displays size', async () => {
- await page.goto(url.create)
- await page.waitForURL(url.create)
+ await page.goto(pagesURLUtil.create)
+ await page.waitForURL(pagesURLUtil.create)
await page.locator('#field-title').fill('Title 3')
await page.locator('#field-slug').fill('slug-3')
await saveDocAndAssert(page)
- await goToCollectionPreview(page)
+ await goToCollectionPreview(page, pagesURLUtil)
const iframe = page.locator('iframe')
@@ -213,13 +254,13 @@ describe('Live Preview', () => {
})
test('resizes iframe to specified breakpoint', async () => {
- await page.goto(url.create)
- await page.waitForURL(url.create)
+ await page.goto(pagesURLUtil.create)
+ await page.waitForURL(pagesURLUtil.create)
await page.locator('#field-title').fill('Title 4')
await page.locator('#field-slug').fill('slug-4')
await saveDocAndAssert(page)
- await goToCollectionPreview(page)
+ await goToCollectionPreview(page, pagesURLUtil)
// Check that the breakpoint select is present
const breakpointSelector = page.locator(
diff --git a/test/live-preview/seed/index.ts b/test/live-preview/seed/index.ts
index 4621d5075..3eda7706f 100644
--- a/test/live-preview/seed/index.ts
+++ b/test/live-preview/seed/index.ts
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'
import { devUser } from '../../credentials.js'
import removeFiles from '../../helpers/removeFiles.js'
import { postsSlug } from '../collections/Posts.js'
-import { pagesSlug, tenantsSlug } from '../shared.js'
+import { pagesSlug, ssrPostsSlug, tenantsSlug } from '../shared.js'
import { footer } from './footer.js'
import { header } from './header.js'
import { home } from './home.js'
@@ -61,6 +61,22 @@ export const seed: Config['onInit'] = async (payload) => {
),
})
+ await payload.create({
+ collection: ssrPostsSlug,
+ data: {
+ ...JSON.parse(
+ JSON.stringify(post1)
+ .replace(/"\{\{IMAGE\}\}"/g, mediaID)
+ .replace(/"\{\{TENANT_1_ID\}\}"/g, tenantID),
+ ),
+ title: 'SSR Post 1',
+ meta: {
+ title: 'SSR Post 1',
+ description: 'This is the first SSR post.',
+ },
+ },
+ })
+
const post2Doc = await payload.create({
collection: postsSlug,
data: JSON.parse(
diff --git a/test/live-preview/shared.ts b/test/live-preview/shared.ts
index 1ced8ab60..54f3d9598 100644
--- a/test/live-preview/shared.ts
+++ b/test/live-preview/shared.ts
@@ -2,9 +2,13 @@ export const pagesSlug = 'pages'
export const tenantsSlug = 'tenants'
+export const ssrPostsSlug = 'posts-ssr'
+
export const mobileBreakpoint = {
label: 'Mobile',
name: 'mobile',
width: 375,
height: 667,
}
+
+export const renderedPageTitleID = 'rendered-page-title'
diff --git a/test/live-preview/tsconfig.json b/test/live-preview/tsconfig.json
index b85fda49c..c5b6c9c88 100644
--- a/test/live-preview/tsconfig.json
+++ b/test/live-preview/tsconfig.json
@@ -35,7 +35,7 @@
"@payloadcms/ui/utilities/*": ["../../packages/ui/src/utilities/*.ts"],
"@payloadcms/ui/scss": ["../../packages/ui/src/scss.scss"],
"@payloadcms/ui/scss/app.scss": ["../../packages/ui/src/scss/app.scss"],
- "payload/types": ["../../packages/payload/src/exports/types/index.ts"],
+ "payload/types": ["../../packages/payload/src/exports/types.ts"],
"@payloadcms/next/*": ["../../packages/next/src/*"],
"@payloadcms/next": ["../../packages/next/src/exports/*"],
}