feat(ui): live preview conditions (#14012)
Supports live preview conditions. This is essentially access control for
live preview, where you may want to restrict who can use it based on
certain criteria, such as the current user or document data.
To do this, simply return null or undefined from your live preview url
functions:
```ts
url: ({ req }) => (req.user?.role === 'admin' ? '/hello-world' : null)
```
This is also useful for pages which derive their URL from document data,
e.g. a slug field, do not attempt to render the live preview window
until the URL is fully formatted.
For example, if you have a page in your front-end with the URL structure
of `/posts/[slug]`, the slug field is required before the page can
properly load. However, if the slug is not a required field, or when
drafts and/or autosave is enabled, the slug field might not yet have
data, leading to `/posts/undefined` or similar.
```ts
url: ({ data }) => data?.slug ? `/${data.slug}` : null
```
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211513433305000
This commit is contained in:
@@ -39,7 +39,7 @@ const config = buildConfig({
|
|||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
Setting up Live Preview is easy. This can be done either globally through the [Root Admin Config](../admin/overview), or on individual [Collection Admin Configs](../configuration/collections#admin-options) and [Global Admin Configs](../configuration/globals#admin-options). Once configured, a new "Live Preview" tab will appear at the top of enabled Documents. Navigating to this tab opens the preview window and loads your front-end application.
|
Setting up Live Preview is easy. This can be done either globally through the [Root Admin Config](../admin/overview), or on individual [Collection Admin Configs](../configuration/collections#admin-options) and [Global Admin Configs](../configuration/globals#admin-options). Once configured, a new "Live Preview" button will appear at the top of enabled Documents. Toggling this button opens the preview window and loads your front-end application.
|
||||||
|
|
||||||
The following options are available:
|
The following options are available:
|
||||||
|
|
||||||
@@ -75,6 +75,8 @@ const config = buildConfig({
|
|||||||
|
|
||||||
You can also pass a function in order to dynamically format URLs. This is useful for multi-tenant applications, localization, or any other scenario where the URL needs to be generated based on the Document being edited.
|
You can also pass a function in order to dynamically format URLs. This is useful for multi-tenant applications, localization, or any other scenario where the URL needs to be generated based on the Document being edited.
|
||||||
|
|
||||||
|
This is also useful for conditionally rendering Live Preview, similar to access control. See [Conditional Rendering](./conditional-rendering) for more details.
|
||||||
|
|
||||||
To set dynamic URLs, set the `admin.livePreview.url` property in your [Payload Config](../configuration/overview) to a function:
|
To set dynamic URLs, set the `admin.livePreview.url` property in your [Payload Config](../configuration/overview) to a function:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -114,7 +116,17 @@ You can return either an absolute URL or relative URL from this function. If you
|
|||||||
If your application requires a fully qualified URL, or you are attempting to preview with a frontend on a different domain, you can use the `req` property to build this URL:
|
If your application requires a fully qualified URL, or you are attempting to preview with a frontend on a different domain, you can use the `req` property to build this URL:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
url: ({ data, req }) => `${req.protocol}//${req.host}/${data.slug}` // highlight-line
|
url: ({ data, req }) => `${req.protocol}//${req.host}/${data.slug}`
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Conditional Rendering
|
||||||
|
|
||||||
|
You can conditionally render Live Preview by returning `undefined` or `null` from the `url` function. This is similar to access control, where you may want to restrict who can use Live Preview based on certain criteria, such as the current user or document data.
|
||||||
|
|
||||||
|
For example, you could check the user's role and only enable Live Preview if they have the appropriate permissions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
url: ({ req }) => (req.user?.role === 'admin' ? '/hello-world' : null)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Breakpoints
|
### Breakpoints
|
||||||
|
|||||||
@@ -444,7 +444,9 @@ export type CollectionAdminOptions = {
|
|||||||
*/
|
*/
|
||||||
listSearchableFields?: string[]
|
listSearchableFields?: string[]
|
||||||
/**
|
/**
|
||||||
* Live preview options
|
* Live Preview options.
|
||||||
|
*
|
||||||
|
* @see https://payloadcms.com/docs/live-preview/overview
|
||||||
*/
|
*/
|
||||||
livePreview?: LivePreviewConfig
|
livePreview?: LivePreviewConfig
|
||||||
meta?: MetaConfig
|
meta?: MetaConfig
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations'
|
|||||||
import type { DeepPartial } from 'ts-essentials'
|
import type { DeepPartial } from 'ts-essentials'
|
||||||
|
|
||||||
import type { ImportMap } from '../bin/generateImportMap/index.js'
|
import type { ImportMap } from '../bin/generateImportMap/index.js'
|
||||||
import type { ClientBlock, ClientField, Field } from '../fields/config/types.js'
|
import type { ClientBlock } from '../fields/config/types.js'
|
||||||
import type { BlockSlug, TypedUser } from '../index.js'
|
import type { BlockSlug, TypedUser } from '../index.js'
|
||||||
import type {
|
import type {
|
||||||
RootLivePreviewConfig,
|
RootLivePreviewConfig,
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ type Prettify<T> = {
|
|||||||
|
|
||||||
export type Plugin = (config: Config) => Config | Promise<Config>
|
export type Plugin = (config: Config) => Config | Promise<Config>
|
||||||
|
|
||||||
|
export type LivePreviewURLType = null | string | undefined
|
||||||
|
|
||||||
export type LivePreviewConfig = {
|
export type LivePreviewConfig = {
|
||||||
/**
|
/**
|
||||||
Device breakpoints to use for the `iframe` of the Live Preview window.
|
Device breakpoints to use for the `iframe` of the Live Preview window.
|
||||||
@@ -154,7 +156,9 @@ export type LivePreviewConfig = {
|
|||||||
* The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
|
* The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
|
||||||
* Payload will send a `window.postMessage()` to this URL with the document data in real-time.
|
* Payload will send a `window.postMessage()` to this URL with the document data in real-time.
|
||||||
* The frontend application is responsible for receiving the message and updating the UI accordingly.
|
* The frontend application is responsible for receiving the message and updating the UI accordingly.
|
||||||
* Use the `useLivePreview` hook to get started in React applications.
|
* @see https://payloadcms.com/docs/live-preview/frontend
|
||||||
|
*
|
||||||
|
* To conditionally render Live Preview, use a function that returns `undefined` or `null`.
|
||||||
*
|
*
|
||||||
* Note: this function may run often if autosave is enabled with a small interval.
|
* Note: this function may run often if autosave is enabled with a small interval.
|
||||||
* For performance, avoid long-running tasks or expensive operations within this function,
|
* For performance, avoid long-running tasks or expensive operations within this function,
|
||||||
@@ -172,8 +176,8 @@ export type LivePreviewConfig = {
|
|||||||
*/
|
*/
|
||||||
payload: Payload
|
payload: Payload
|
||||||
req: PayloadRequest
|
req: PayloadRequest
|
||||||
}) => Promise<string> | string)
|
}) => LivePreviewURLType | Promise<LivePreviewURLType>)
|
||||||
| string
|
| LivePreviewURLType
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RootLivePreviewConfig = {
|
export type RootLivePreviewConfig = {
|
||||||
@@ -884,6 +888,11 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
importMapFile?: string
|
importMapFile?: string
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Live Preview options.
|
||||||
|
*
|
||||||
|
* @see https://payloadcms.com/docs/live-preview/overview
|
||||||
|
*/
|
||||||
livePreview?: RootLivePreviewConfig
|
livePreview?: RootLivePreviewConfig
|
||||||
/** Base meta data to use for the Admin Panel. Included properties are titleSuffix, ogImage, and favicon. */
|
/** Base meta data to use for the Admin Panel. Included properties are titleSuffix, ogImage, and favicon. */
|
||||||
meta?: MetaConfig
|
meta?: MetaConfig
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import './index.scss'
|
|||||||
const baseClass = 'live-preview-toggler'
|
const baseClass = 'live-preview-toggler'
|
||||||
|
|
||||||
export const LivePreviewToggler: React.FC = () => {
|
export const LivePreviewToggler: React.FC = () => {
|
||||||
const { isLivePreviewing, setIsLivePreviewing } = useLivePreviewContext()
|
const { isLivePreviewing, setIsLivePreviewing, url: livePreviewURL } = useLivePreviewContext()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (!livePreviewURL) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={isLivePreviewing ? t('general:exitLivePreview') : t('general:livePreview')}
|
aria-label={isLivePreviewing ? t('general:exitLivePreview') : t('general:livePreview')}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { LivePreviewConfig } from 'payload'
|
import type { LivePreviewConfig, LivePreviewURLType } from 'payload'
|
||||||
import type { Dispatch } from 'react'
|
import type { Dispatch } from 'react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export interface LivePreviewContextType {
|
|||||||
* It is important to know which one it is, so that we can opt in/out of certain behaviors, e.g. calling the server to get the URL.
|
* It is important to know which one it is, so that we can opt in/out of certain behaviors, e.g. calling the server to get the URL.
|
||||||
*/
|
*/
|
||||||
typeofLivePreviewURL?: 'function' | 'string'
|
typeofLivePreviewURL?: 'function' | 'string'
|
||||||
url: string | undefined
|
url: LivePreviewURLType
|
||||||
zoom: number
|
zoom: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { CollectionPreferences, LivePreviewConfig } from 'payload'
|
import type { CollectionPreferences, LivePreviewConfig, LivePreviewURLType } from 'payload'
|
||||||
|
|
||||||
import { DndContext } from '@dnd-kit/core'
|
import { DndContext } from '@dnd-kit/core'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -92,7 +92,11 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
|||||||
*/
|
*/
|
||||||
const setLivePreviewURL = useCallback<LivePreviewContextType['setURL']>(
|
const setLivePreviewURL = useCallback<LivePreviewContextType['setURL']>(
|
||||||
(_incomingURL) => {
|
(_incomingURL) => {
|
||||||
const incomingURL = formatAbsoluteURL(_incomingURL)
|
let incomingURL: LivePreviewURLType
|
||||||
|
|
||||||
|
if (typeof _incomingURL === 'string') {
|
||||||
|
incomingURL = formatAbsoluteURL(_incomingURL)
|
||||||
|
}
|
||||||
|
|
||||||
if (incomingURL !== url) {
|
if (incomingURL !== url) {
|
||||||
setAppIsReady(false)
|
setAppIsReady(false)
|
||||||
@@ -106,7 +110,9 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
|||||||
* `url` needs to be relative to the window, which cannot be done on initial render.
|
* `url` needs to be relative to the window, which cannot be done on initial render.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setURL(formatAbsoluteURL(urlFromProps))
|
if (typeof urlFromProps === 'string') {
|
||||||
|
setURL(formatAbsoluteURL(urlFromProps))
|
||||||
|
}
|
||||||
}, [urlFromProps])
|
}, [urlFromProps])
|
||||||
|
|
||||||
// The toolbar needs to freely drag and drop around the page
|
// The toolbar needs to freely drag and drop around the page
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
CollectionConfig,
|
CollectionConfig,
|
||||||
GlobalConfig,
|
GlobalConfig,
|
||||||
LivePreviewConfig,
|
LivePreviewConfig,
|
||||||
|
LivePreviewURLType,
|
||||||
Operation,
|
Operation,
|
||||||
PayloadRequest,
|
PayloadRequest,
|
||||||
SanitizedConfig,
|
SanitizedConfig,
|
||||||
@@ -80,7 +81,7 @@ export const handleLivePreview = async ({
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
isLivePreviewEnabled?: boolean
|
isLivePreviewEnabled?: boolean
|
||||||
livePreviewConfig?: LivePreviewConfig
|
livePreviewConfig?: LivePreviewConfig
|
||||||
livePreviewURL?: string
|
livePreviewURL?: LivePreviewURLType
|
||||||
}> => {
|
}> => {
|
||||||
const collectionConfig = collectionSlug
|
const collectionConfig = collectionSlug
|
||||||
? req.payload.collections[collectionSlug]?.config
|
? req.payload.collections[collectionSlug]?.config
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export function DefaultEditView({
|
|||||||
previewWindowType,
|
previewWindowType,
|
||||||
setURL: setLivePreviewURL,
|
setURL: setLivePreviewURL,
|
||||||
typeofLivePreviewURL,
|
typeofLivePreviewURL,
|
||||||
|
url: livePreviewURL,
|
||||||
} = useLivePreviewContext()
|
} = useLivePreviewContext()
|
||||||
|
|
||||||
const abortOnChangeRef = useRef<AbortController>(null)
|
const abortOnChangeRef = useRef<AbortController>(null)
|
||||||
@@ -353,7 +354,7 @@ export function DefaultEditView({
|
|||||||
setDocumentIsLocked(false)
|
setDocumentIsLocked(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (livePreviewURL) {
|
if (isLivePreviewEnabled && typeofLivePreviewURL === 'function') {
|
||||||
setLivePreviewURL(livePreviewURL)
|
setLivePreviewURL(livePreviewURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +693,7 @@ export function DefaultEditView({
|
|||||||
/>
|
/>
|
||||||
{AfterDocument}
|
{AfterDocument}
|
||||||
</div>
|
</div>
|
||||||
{isLivePreviewEnabled && !isInDrawer && (
|
{isLivePreviewEnabled && !isInDrawer && livePreviewURL && (
|
||||||
<LivePreviewWindow collectionSlug={collectionSlug} globalSlug={globalSlug} />
|
<LivePreviewWindow collectionSlug={collectionSlug} globalSlug={globalSlug} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
19
test/live-preview/app/live-preview/(pages)/static/page.tsx
Normal file
19
test/live-preview/app/live-preview/(pages)/static/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Gutter } from '@payloadcms/ui'
|
||||||
|
|
||||||
|
import React, { Fragment } from 'react'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{
|
||||||
|
slug?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TestPage(args: Args) {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Gutter>
|
||||||
|
<p>This is a static page for testing.</p>
|
||||||
|
</Gutter>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
test/live-preview/collections/NoURL.ts
Normal file
20
test/live-preview/collections/NoURL.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
export const NoURLCollection: CollectionConfig = {
|
||||||
|
slug: 'no-url',
|
||||||
|
admin: {
|
||||||
|
livePreview: {
|
||||||
|
url: ({ data }) => (data?.enabled ? '/live-preview/static' : null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enabled',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { MediaBlock } from './blocks/MediaBlock/index.js'
|
|||||||
import { Categories } from './collections/Categories.js'
|
import { Categories } from './collections/Categories.js'
|
||||||
import { CollectionLevelConfig } from './collections/CollectionLevelConfig.js'
|
import { CollectionLevelConfig } from './collections/CollectionLevelConfig.js'
|
||||||
import { Media } from './collections/Media.js'
|
import { Media } from './collections/Media.js'
|
||||||
|
import { NoURLCollection } from './collections/NoURL.js'
|
||||||
import { Pages } from './collections/Pages.js'
|
import { Pages } from './collections/Pages.js'
|
||||||
import { Posts } from './collections/Posts.js'
|
import { Posts } from './collections/Posts.js'
|
||||||
import { SSR } from './collections/SSR.js'
|
import { SSR } from './collections/SSR.js'
|
||||||
@@ -58,6 +59,7 @@ export default buildConfigWithDefaults({
|
|||||||
Media,
|
Media,
|
||||||
CollectionLevelConfig,
|
CollectionLevelConfig,
|
||||||
StaticURLCollection,
|
StaticURLCollection,
|
||||||
|
NoURLCollection,
|
||||||
],
|
],
|
||||||
globals: [Header, Footer],
|
globals: [Header, Footer],
|
||||||
onInit: seed,
|
onInit: seed,
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { fileURLToPath } from 'url'
|
|||||||
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
||||||
|
|
||||||
import { devUser } from '../credentials.js'
|
import { devUser } from '../credentials.js'
|
||||||
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
|
import {
|
||||||
|
ensureCompilationIsDone,
|
||||||
|
initPageConsoleErrorCatch,
|
||||||
|
saveDocAndAssert,
|
||||||
|
// throttleTest,
|
||||||
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
import {
|
import {
|
||||||
selectLivePreviewBreakpoint,
|
selectLivePreviewBreakpoint,
|
||||||
@@ -55,6 +60,7 @@ describe('Live Preview', () => {
|
|||||||
let ssrAutosavePagesURLUtil: AdminUrlUtil
|
let ssrAutosavePagesURLUtil: AdminUrlUtil
|
||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
let user: any
|
let user: any
|
||||||
|
let context: any
|
||||||
|
|
||||||
beforeAll(async ({ browser }, testInfo) => {
|
beforeAll(async ({ browser }, testInfo) => {
|
||||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||||
@@ -65,7 +71,7 @@ describe('Live Preview', () => {
|
|||||||
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
|
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
|
||||||
ssrAutosavePagesURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
|
ssrAutosavePagesURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
|
||||||
|
|
||||||
const context = await browser.newContext()
|
context = await browser.newContext()
|
||||||
page = await context.newPage()
|
page = await context.newPage()
|
||||||
|
|
||||||
initPageConsoleErrorCatch(page)
|
initPageConsoleErrorCatch(page)
|
||||||
@@ -83,6 +89,12 @@ describe('Live Preview', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
// await throttleTest({
|
||||||
|
// page,
|
||||||
|
// context,
|
||||||
|
// delay: 'Fast 4G',
|
||||||
|
// })
|
||||||
|
|
||||||
await reInitializeDB({
|
await reInitializeDB({
|
||||||
serverURL,
|
serverURL,
|
||||||
snapshotKey: 'livePreviewTest',
|
snapshotKey: 'livePreviewTest',
|
||||||
@@ -164,6 +176,24 @@ describe('Live Preview', () => {
|
|||||||
await expect.poll(async () => iframe.getAttribute('src')).toMatch(/\/live-preview/)
|
await expect.poll(async () => iframe.getAttribute('src')).toMatch(/\/live-preview/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('collection — does not render iframe when live preview url is falsy', async () => {
|
||||||
|
const noURL = new AdminUrlUtil(serverURL, 'no-url')
|
||||||
|
await page.goto(noURL.create)
|
||||||
|
await page.locator('#field-title').fill('No URL')
|
||||||
|
await saveDocAndAssert(page)
|
||||||
|
const toggler = page.locator('button#live-preview-toggler')
|
||||||
|
await expect(toggler).toBeHidden()
|
||||||
|
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
|
||||||
|
|
||||||
|
const enabledCheckbox = page.locator('#field-enabled')
|
||||||
|
await enabledCheckbox.check()
|
||||||
|
await saveDocAndAssert(page)
|
||||||
|
|
||||||
|
await expect(toggler).toBeVisible()
|
||||||
|
await toggleLivePreview(page)
|
||||||
|
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test('collection — retains static URL across edits', async () => {
|
test('collection — retains static URL across edits', async () => {
|
||||||
const util = new AdminUrlUtil(serverURL, 'static-url')
|
const util = new AdminUrlUtil(serverURL, 'static-url')
|
||||||
await page.goto(util.create)
|
await page.goto(util.create)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Gutter } from '@payloadcms/ui'
|
||||||
|
|
||||||
|
import React, { Fragment } from 'react'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{
|
||||||
|
slug?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TestPage(args: Args) {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Gutter>
|
||||||
|
<p>This is a static page for testing.</p>
|
||||||
|
</Gutter>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user