feat: custom view and document-level metadata (#7716)

This commit is contained in:
Jacob Fletcher
2024-08-18 23:22:38 -04:00
committed by GitHub
parent 2835e1d709
commit a526c7becd
25 changed files with 645 additions and 281 deletions

View File

@@ -45,7 +45,7 @@ export const meta = async (args: { serverURL: string } & MetaConfig): Promise<an
icons = customIcons icons = customIcons
} }
const metaTitle = `${title} ${titleSuffix}` const metaTitle = [title, titleSuffix].filter(Boolean).join(' ')
const ogTitle = `${typeof openGraphFromProps?.title === 'string' ? openGraphFromProps.title : title} ${titleSuffix}` const ogTitle = `${typeof openGraphFromProps?.title === 'string' ? openGraphFromProps.title : title} ${titleSuffix}`

View File

@@ -16,18 +16,25 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
? getTranslation(globalConfig.label, i18n) ? getTranslation(globalConfig.label, i18n)
: '' : ''
const metaTitle = `API - ${entityLabel}`
const description = `API - ${entityLabel}`
return Promise.resolve( return Promise.resolve(
meta({ meta({
...(config.admin.meta || {}), ...(config.admin.meta || {}),
description, description: `API - ${entityLabel}`,
keywords: 'API', keywords: 'API',
serverURL: config.serverURL, serverURL: config.serverURL,
title: metaTitle, title: `API - ${entityLabel}`,
...(collectionConfig?.admin.meta || {}), ...(collectionConfig
...(globalConfig?.admin.meta || {}), ? {
...(collectionConfig?.admin.meta || {}),
...(collectionConfig?.admin?.components?.views?.edit?.api?.meta || {}),
}
: {}),
...(globalConfig
? {
...(globalConfig?.admin.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.api?.meta || {}),
}
: {}),
}), }),
) )
} }

View File

@@ -12,25 +12,42 @@ export const getCustomViewByRoute = ({
views: views:
| SanitizedCollectionConfig['admin']['components']['views'] | SanitizedCollectionConfig['admin']['components']['views']
| SanitizedGlobalConfig['admin']['components']['views'] | SanitizedGlobalConfig['admin']['components']['views']
}): EditViewComponent => { }): {
if (typeof views?.edit === 'object' && typeof views?.edit !== 'function') { Component: EditViewComponent
const foundViewConfig = Object.entries(views.edit).find(([, view]) => { viewKey?: string
if (typeof view === 'object' && typeof view !== 'function' && 'path' in view) { } => {
if (typeof views?.edit === 'object') {
let viewKey: string
const foundViewConfig = Object.entries(views.edit).find(([key, view]) => {
if (typeof view === 'object' && 'path' in view) {
const viewPath = `${baseRoute}${view.path}` const viewPath = `${baseRoute}${view.path}`
return isPathMatchingRoute({ const isMatching = isPathMatchingRoute({
currentRoute, currentRoute,
exact: true, exact: true,
path: viewPath, path: viewPath,
}) })
if (isMatching) {
viewKey = key
}
return isMatching
} }
return false return false
})?.[1] })?.[1]
if (foundViewConfig && 'Component' in foundViewConfig) { if (foundViewConfig && 'Component' in foundViewConfig) {
return foundViewConfig.Component return {
Component: foundViewConfig.Component,
viewKey,
}
} }
} }
return null return {
Component: null,
}
} }

View File

@@ -1,5 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import type { SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload' import type { EditConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
import type { GenerateViewMetadata } from '../Root/index.js' import type { GenerateViewMetadata } from '../Root/index.js'
@@ -10,11 +10,13 @@ import { generateMetadata as livePreviewMeta } from '../LivePreview/meta.js'
import { generateNotFoundMeta } from '../NotFound/meta.js' import { generateNotFoundMeta } from '../NotFound/meta.js'
import { generateMetadata as versionMeta } from '../Version/meta.js' import { generateMetadata as versionMeta } from '../Version/meta.js'
import { generateMetadata as versionsMeta } from '../Versions/meta.js' import { generateMetadata as versionsMeta } from '../Versions/meta.js'
import { getViewsFromConfig } from './getViewsFromConfig.js'
export type GenerateEditViewMetadata = ( export type GenerateEditViewMetadata = (
args: { args: {
collectionConfig?: SanitizedCollectionConfig | null collectionConfig?: SanitizedCollectionConfig | null
globalConfig?: SanitizedGlobalConfig | null globalConfig?: SanitizedGlobalConfig | null
view?: keyof EditConfig
} & Parameters<GenerateViewMetadata>[0], } & Parameters<GenerateViewMetadata>[0],
) => Promise<Metadata> ) => Promise<Metadata>
@@ -36,54 +38,71 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
isGlobal || Boolean(isCollection && segments?.length > 2 && segments[2] !== 'create') isGlobal || Boolean(isCollection && segments?.length > 2 && segments[2] !== 'create')
if (isCollection) { if (isCollection) {
// `/:id` // `/:collection/:id`
if (params.segments.length === 3) { if (params.segments.length === 3) {
fn = editMeta fn = editMeta
} }
// `/:id/api` // `/:collection/:id/:view`
if (params.segments.length === 4 && params.segments[3] === 'api') { if (params.segments.length === 4) {
fn = apiMeta switch (params.segments[3]) {
case 'api':
// `/:collection/:id/api`
fn = apiMeta
break
case 'preview':
// `/:collection/:id/preview`
fn = livePreviewMeta
break
case 'versions':
// `/:collection/:id/versions`
fn = versionsMeta
break
default:
break
}
} }
// `/:id/preview` // `/:collection/:id/:slug-1/:slug-2`
if (params.segments.length === 4 && params.segments[3] === 'preview') { if (params.segments.length === 5) {
fn = livePreviewMeta switch (params.segments[3]) {
} case 'versions':
// `/:collection/:id/versions/:version`
// `/:id/versions` fn = versionMeta
if (params.segments.length === 4 && params.segments[3] === 'versions') { break
fn = versionsMeta default:
} break
}
// `/:id/versions/:version`
if (params.segments.length === 5 && params.segments[3] === 'versions') {
fn = versionMeta
} }
} }
if (isGlobal) { if (isGlobal) {
// `/:slug` // `/:global`
if (params.segments?.length === 2) { if (params.segments?.length === 2) {
fn = editMeta fn = editMeta
} }
// `/:slug/api` // `/:global/:view`
if (params.segments?.length === 3 && params.segments[2] === 'api') { if (params.segments?.length === 3) {
fn = apiMeta switch (params.segments[2]) {
case 'api':
// `/:global/api`
fn = apiMeta
break
case 'preview':
// `/:global/preview`
fn = livePreviewMeta
break
case 'versions':
// `/:global/versions`
fn = versionsMeta
break
default:
break
}
} }
// `/:slug/preview` // `/:global/versions/:version`
if (params.segments?.length === 3 && params.segments[2] === 'preview') {
fn = livePreviewMeta
}
// `/:slug/versions`
if (params.segments?.length === 3 && params.segments[2] === 'versions') {
fn = versionsMeta
}
// `/:slug/versions/:version`
if (params.segments?.length === 4 && params.segments[2] === 'versions') { if (params.segments?.length === 4 && params.segments[2] === 'versions') {
fn = versionMeta fn = versionMeta
} }
@@ -101,6 +120,31 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
i18n, i18n,
isEditing, isEditing,
}) })
} else {
const { viewKey } = getViewsFromConfig({
collectionConfig,
config,
globalConfig,
overrideDocPermissions: true,
routeSegments: typeof segments === 'string' ? [segments] : segments,
})
if (viewKey) {
const customViewConfig =
collectionConfig?.admin?.components?.views?.edit?.[viewKey] ||
globalConfig?.admin?.components?.views?.edit?.[viewKey]
if (customViewConfig) {
return editMeta({
collectionConfig,
config,
globalConfig,
i18n,
isEditing,
view: viewKey as keyof EditConfig,
})
}
}
} }
return generateNotFoundMeta({ config, i18n }) return generateNotFoundMeta({ config, i18n })

View File

@@ -31,25 +31,36 @@ export const getViewsFromConfig = ({
config, config,
docPermissions, docPermissions,
globalConfig, globalConfig,
overrideDocPermissions,
routeSegments, routeSegments,
}: { }: {
collectionConfig?: SanitizedCollectionConfig collectionConfig?: SanitizedCollectionConfig
config: SanitizedConfig config: SanitizedConfig
docPermissions: CollectionPermission | GlobalPermission
globalConfig?: SanitizedGlobalConfig globalConfig?: SanitizedGlobalConfig
routeSegments: string[] routeSegments: string[]
}): { } & (
| {
docPermissions: CollectionPermission | GlobalPermission
overrideDocPermissions?: false | undefined
}
| {
docPermissions?: never
overrideDocPermissions: true
}
)): {
CustomView: ViewFromConfig<ServerSideEditViewProps> CustomView: ViewFromConfig<ServerSideEditViewProps>
DefaultView: ViewFromConfig<ServerSideEditViewProps> DefaultView: ViewFromConfig<ServerSideEditViewProps>
/** /**
* The error view to display if CustomView or DefaultView do not exist (could be either due to not found, or unauthorized). Can be null * The error view to display if CustomView or DefaultView do not exist (could be either due to not found, or unauthorized). Can be null
*/ */
ErrorView: ViewFromConfig<AdminViewProps> ErrorView: ViewFromConfig<AdminViewProps>
viewKey: string
} | null => { } | null => {
// Conditionally import and lazy load the default view // Conditionally import and lazy load the default view
let DefaultView: ViewFromConfig<ServerSideEditViewProps> = null let DefaultView: ViewFromConfig<ServerSideEditViewProps> = null
let CustomView: ViewFromConfig<ServerSideEditViewProps> = null let CustomView: ViewFromConfig<ServerSideEditViewProps> = null
let ErrorView: ViewFromConfig<AdminViewProps> = null let ErrorView: ViewFromConfig<AdminViewProps> = null
let viewKey: string
const { const {
routes: { admin: adminRoute }, routes: { admin: adminRoute },
@@ -69,7 +80,7 @@ export const getViewsFromConfig = ({
const [collectionEntity, collectionSlug, segment3, segment4, segment5, ...remainingSegments] = const [collectionEntity, collectionSlug, segment3, segment4, segment5, ...remainingSegments] =
routeSegments routeSegments
if (!docPermissions?.read?.permission) { if (!overrideDocPermissions && !docPermissions?.read?.permission) {
notFound() notFound()
} else { } else {
// `../:id`, or `../create` // `../:id`, or `../create`
@@ -77,7 +88,11 @@ export const getViewsFromConfig = ({
case 3: { case 3: {
switch (segment3) { switch (segment3) {
case 'create': { case 'create': {
if ('create' in docPermissions && docPermissions?.create?.permission) { if (
!overrideDocPermissions &&
'create' in docPermissions &&
docPermissions?.create?.permission
) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'), payloadComponent: getCustomViewByKey(views, 'default'),
} }
@@ -93,12 +108,44 @@ export const getViewsFromConfig = ({
} }
default: { default: {
CustomView = { const baseRoute = [
payloadComponent: getCustomViewByKey(views, 'default'), adminRoute !== '/' && adminRoute,
} 'collections',
DefaultView = { collectionSlug,
Component: DefaultEditView, segment3,
]
.filter(Boolean)
.join('/')
const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments]
.filter(Boolean)
.join('/')
const { Component: CustomViewComponent, viewKey: customViewKey } =
getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
console.log('CustomViewComponent', customViewKey)
if (customViewKey) {
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
}
} else {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
}
} }
break break
} }
} }
@@ -130,7 +177,7 @@ export const getViewsFromConfig = ({
} }
case 'versions': { case 'versions': {
if (docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'), payloadComponent: getCustomViewByKey(views, 'versions'),
} }
@@ -159,13 +206,21 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = { const { Component: CustomViewComponent, viewKey: customViewKey } =
payloadComponent: getCustomViewByRoute({ getCustomViewByRoute({
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}), })
if (customViewKey) {
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
}
} }
break break
} }
} }
@@ -175,7 +230,7 @@ export const getViewsFromConfig = ({
// `../:id/versions/:version`, etc // `../:id/versions/:version`, etc
default: { default: {
if (segment4 === 'versions') { if (segment4 === 'versions') {
if (docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'), payloadComponent: getCustomViewByKey(views, 'version'),
} }
@@ -201,14 +256,23 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = { const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute(
payloadComponent: getCustomViewByRoute({ {
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}), },
)
if (customViewKey) {
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
}
} }
} }
break break
} }
} }
@@ -218,7 +282,7 @@ export const getViewsFromConfig = ({
if (globalConfig) { if (globalConfig) {
const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments
if (!docPermissions?.read?.permission) { if (!overrideDocPermissions && !docPermissions?.read?.permission) {
notFound() notFound()
} else { } else {
switch (routeSegments.length) { switch (routeSegments.length) {
@@ -257,10 +321,11 @@ export const getViewsFromConfig = ({
} }
case 'versions': { case 'versions': {
if (docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'), payloadComponent: getCustomViewByKey(views, 'versions'),
} }
DefaultView = { DefaultView = {
Component: DefaultVersionsView, Component: DefaultVersionsView,
} }
@@ -273,7 +338,7 @@ export const getViewsFromConfig = ({
} }
default: { default: {
if (docPermissions?.read?.permission) { if (!overrideDocPermissions && docPermissions?.read?.permission) {
const baseRoute = [adminRoute, globalEntity, globalSlug, segment3] const baseRoute = [adminRoute, globalEntity, globalSlug, segment3]
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
@@ -282,15 +347,23 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = { const { Component: CustomViewComponent, viewKey: customViewKey } =
payloadComponent: getCustomViewByRoute({ getCustomViewByRoute({
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}), })
}
DefaultView = { if (customViewKey) {
Component: DefaultEditView, viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
}
} else {
DefaultView = {
Component: DefaultEditView,
}
} }
} else { } else {
ErrorView = { ErrorView = {
@@ -306,7 +379,7 @@ export const getViewsFromConfig = ({
default: { default: {
// `../:slug/versions/:version`, etc // `../:slug/versions/:version`, etc
if (segment3 === 'versions') { if (segment3 === 'versions') {
if (docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'), payloadComponent: getCustomViewByKey(views, 'version'),
} }
@@ -327,14 +400,23 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = { const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute(
payloadComponent: getCustomViewByRoute({ {
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}), },
)
if (customViewKey) {
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
}
} }
} }
break break
} }
} }
@@ -345,5 +427,6 @@ export const getViewsFromConfig = ({
CustomView, CustomView,
DefaultView, DefaultView,
ErrorView, ErrorView,
viewKey,
} }
} }

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import type { MetaConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
@@ -12,6 +13,7 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
globalConfig, globalConfig,
i18n, i18n,
isEditing, isEditing,
view = 'default',
}): Promise<Metadata> => { }): Promise<Metadata> => {
const { t } = i18n const { t } = i18n
@@ -21,34 +23,45 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
? getTranslation(globalConfig.label, i18n) ? getTranslation(globalConfig.label, i18n)
: '' : ''
const metaTitle = `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}` const metaToUse: MetaConfig = {
...(config.admin.meta || {}),
description: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`,
keywords: `${entityLabel}, Payload, CMS`,
title: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`,
}
const ogTitle = `${isEditing ? t('general:edit') : t('general:edit')} - ${entityLabel}` const ogToUse: MetaConfig['openGraph'] = {
title: `${isEditing ? t('general:edit') : t('general:edit')} - ${entityLabel}`,
const description = `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}` ...(config.admin.meta.openGraph || {}),
...(collectionConfig
const keywords = `${entityLabel}, Payload, CMS` ? {
...(collectionConfig?.admin.meta?.openGraph || {}),
const baseOGOverrides = config.admin.meta.openGraph || {} ...(collectionConfig?.admin?.components?.views?.edit?.[view]?.meta?.openGraph || {}),
}
const entityOGOverrides = collectionConfig : {}),
? collectionConfig.admin?.meta?.openGraph ...(globalConfig
: globalConfig ? {
? globalConfig.admin?.meta?.openGraph ...(globalConfig?.admin.meta?.openGraph || {}),
: {} ...(globalConfig?.admin?.components?.views?.edit?.[view]?.meta?.openGraph || {}),
}
: {}),
}
return meta({ return meta({
...(config.admin.meta || {}), ...metaToUse,
description, openGraph: ogToUse,
keywords, ...(collectionConfig
openGraph: { ? {
title: ogTitle, ...(collectionConfig?.admin.meta || {}),
...baseOGOverrides, ...(collectionConfig?.admin?.components?.views?.edit?.[view]?.meta || {}),
...entityOGOverrides, }
}, : {}),
...(collectionConfig?.admin.meta || {}), ...(globalConfig
...(globalConfig?.admin.meta || {}), ? {
...(globalConfig?.admin.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.[view]?.meta || {}),
}
: {}),
serverURL: config.serverURL, serverURL: config.serverURL,
title: metaTitle,
}) })
} }

View File

@@ -17,4 +17,5 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
globalConfig, globalConfig,
i18n, i18n,
isEditing, isEditing,
view: 'livePreview',
}) })

View File

@@ -0,0 +1,42 @@
import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type {
AdminViewConfig,
SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig,
} from 'payload'
import { meta } from '../../utilities/meta.js'
export const generateCustomViewMetadata = async (args: {
collectionConfig?: SanitizedCollectionConfig
config: SanitizedConfig
globalConfig?: SanitizedGlobalConfig
i18n: I18nClient
viewConfig: AdminViewConfig
}): Promise<Metadata> => {
const {
config,
// i18n: { t },
viewConfig,
} = args
if (!viewConfig) {
return null
}
return meta({
description: `Payload`,
keywords: `Payload`,
serverURL: config.serverURL,
title: 'Payload',
...(config.admin.meta || {}),
...(viewConfig.meta || {}),
openGraph: {
title: 'Payload',
...(config.admin.meta?.openGraph || {}),
...(viewConfig.meta?.openGraph || {}),
},
})
}

View File

@@ -0,0 +1,65 @@
import type { AdminViewConfig, SanitizedConfig } from 'payload'
import type { ViewFromConfig } from './getViewFromConfig.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
export const getCustomViewByRoute = ({
config,
currentRoute: currentRouteWithAdmin,
}: {
config: SanitizedConfig
currentRoute: string
}): {
view: ViewFromConfig
viewConfig: AdminViewConfig
viewKey: string
} => {
const {
admin: {
components: { views },
},
routes: { admin: adminRoute },
} = config
const currentRoute = currentRouteWithAdmin.replace(adminRoute, '')
let viewKey: string
const foundViewConfig =
(views &&
typeof views === 'object' &&
Object.entries(views).find(([key, view]) => {
const isMatching = isPathMatchingRoute({
currentRoute,
exact: view.exact,
path: view.path,
sensitive: view.sensitive,
strict: view.strict,
})
if (isMatching) {
viewKey = key
}
return isMatching
})?.[1]) ||
undefined
if (!foundViewConfig) {
return {
view: {
Component: null,
},
viewConfig: null,
viewKey: null,
}
}
return {
view: {
payloadComponent: foundViewConfig.Component,
},
viewConfig: foundViewConfig,
viewKey,
}
}

View File

@@ -1,43 +0,0 @@
import type { SanitizedConfig } from 'payload'
import type { ViewFromConfig } from './getViewFromConfig.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
export const getCustomViewByRoute = ({
config,
currentRoute: currentRouteWithAdmin,
}: {
config: SanitizedConfig
currentRoute: string
}): ViewFromConfig => {
const {
admin: {
components: { views },
},
routes: { admin: adminRoute },
} = config
const currentRoute = currentRouteWithAdmin.replace(adminRoute, '')
const foundViewConfig =
views &&
typeof views === 'object' &&
Object.entries(views).find(([, view]) => {
return isPathMatchingRoute({
currentRoute,
exact: view.exact,
path: view.path,
sensitive: view.sensitive,
strict: view.strict,
})
})?.[1]
if (!foundViewConfig) {
return null
}
return {
payloadComponent: foundViewConfig.Component,
}
}

View File

@@ -99,7 +99,7 @@ export const getViewFromConfig = ({
case 1: { case 1: {
// users can override the default routes via `admin.routes` config // users can override the default routes via `admin.routes` config
// i.e.{ admin: { routes: { logout: '/sign-out', inactivity: '/idle' }}} // i.e.{ admin: { routes: { logout: '/sign-out', inactivity: '/idle' }}}
let viewToRender: keyof typeof oneSegmentViews let viewKey: keyof typeof oneSegmentViews
if (config.admin.routes) { if (config.admin.routes) {
const matchedRoute = Object.entries(config.admin.routes).find(([, route]) => { const matchedRoute = Object.entries(config.admin.routes).find(([, route]) => {
@@ -111,11 +111,11 @@ export const getViewFromConfig = ({
}) })
if (matchedRoute) { if (matchedRoute) {
viewToRender = matchedRoute[0] as keyof typeof oneSegmentViews viewKey = matchedRoute[0] as keyof typeof oneSegmentViews
} }
} }
if (oneSegmentViews[viewToRender]) { if (oneSegmentViews[viewKey]) {
// --> /account // --> /account
// --> /create-first-user // --> /create-first-user
// --> /forgot // --> /forgot
@@ -125,12 +125,13 @@ export const getViewFromConfig = ({
// --> /unauthorized // --> /unauthorized
ViewToRender = { ViewToRender = {
Component: oneSegmentViews[viewToRender], Component: oneSegmentViews[viewKey],
} }
templateClassName = baseClasses[viewToRender]
templateClassName = baseClasses[viewKey]
templateType = 'minimal' templateType = 'minimal'
if (viewToRender === 'account') { if (viewKey === 'account') {
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
templateType = 'default' templateType = 'default'
} }
@@ -150,17 +151,21 @@ export const getViewFromConfig = ({
if (isCollection) { if (isCollection) {
// --> /collections/:collectionSlug // --> /collections/:collectionSlug
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = { ViewToRender = {
Component: ListView, Component: ListView,
} }
templateClassName = `${segmentTwo}-list` templateClassName = `${segmentTwo}-list`
templateType = 'default' templateType = 'default'
} else if (isGlobal) { } else if (isGlobal) {
// --> /globals/:globalSlug // --> /globals/:globalSlug
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = { ViewToRender = {
Component: DocumentView, Component: DocumentView,
} }
templateClassName = 'global-edit' templateClassName = 'global-edit'
templateType = 'default' templateType = 'default'
} }
@@ -172,6 +177,7 @@ export const getViewFromConfig = ({
ViewToRender = { ViewToRender = {
Component: Verify, Component: Verify,
} }
templateClassName = 'verify' templateClassName = 'verify'
templateType = 'minimal' templateType = 'minimal'
} else if (isCollection) { } else if (isCollection) {
@@ -182,9 +188,11 @@ export const getViewFromConfig = ({
// --> /collections/:collectionSlug/:id/versions/:versionId // --> /collections/:collectionSlug/:id/versions/:versionId
// --> /collections/:collectionSlug/:id/api // --> /collections/:collectionSlug/:id/api
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = { ViewToRender = {
Component: DocumentView, Component: DocumentView,
} }
templateClassName = `collection-default-edit` templateClassName = `collection-default-edit`
templateType = 'default' templateType = 'default'
} else if (isGlobal) { } else if (isGlobal) {
@@ -194,9 +202,11 @@ export const getViewFromConfig = ({
// --> /globals/:globalSlug/versions/:versionId // --> /globals/:globalSlug/versions/:versionId
// --> /globals/:globalSlug/api // --> /globals/:globalSlug/api
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = { ViewToRender = {
Component: DocumentView, Component: DocumentView,
} }
templateClassName = `global-edit` templateClassName = `global-edit`
templateType = 'default' templateType = 'default'
} }
@@ -204,7 +214,7 @@ export const getViewFromConfig = ({
} }
if (!ViewToRender) { if (!ViewToRender) {
ViewToRender = getCustomViewByRoute({ config, currentRoute }) ViewToRender = getCustomViewByRoute({ config, currentRoute })?.view
} }
return { return {

View File

@@ -13,6 +13,8 @@ import { generateNotFoundMeta } from '../NotFound/meta.js'
import { generateResetPasswordMetadata } from '../ResetPassword/index.js' import { generateResetPasswordMetadata } from '../ResetPassword/index.js'
import { generateUnauthorizedMetadata } from '../Unauthorized/index.js' import { generateUnauthorizedMetadata } from '../Unauthorized/index.js'
import { generateVerifyMetadata } from '../Verify/index.js' import { generateVerifyMetadata } from '../Verify/index.js'
import { generateCustomViewMetadata } from './generateCustomViewMetadata.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
const oneSegmentMeta = { const oneSegmentMeta = {
'create-first-user': generateCreateFirstUserMetadata, 'create-first-user': generateCreateFirstUserMetadata,
@@ -38,6 +40,7 @@ export const generatePageMetadata = async ({ config: configPromise, params }: Ar
const segments = Array.isArray(params.segments) ? params.segments : [] const segments = Array.isArray(params.segments) ? params.segments : []
const currentRoute = `/${segments.join('/')}`
const [segmentOne, segmentTwo] = segments const [segmentOne, segmentTwo] = segments
const isGlobal = segmentOne === 'globals' const isGlobal = segmentOne === 'globals'
@@ -130,7 +133,22 @@ export const generatePageMetadata = async ({ config: configPromise, params }: Ar
} }
if (!meta) { if (!meta) {
meta = await generateNotFoundMeta({ config, i18n }) const { viewConfig, viewKey } = getCustomViewByRoute({
config,
currentRoute,
})
if (viewKey) {
// Custom Views
// --> /:path
meta = await generateCustomViewMetadata({
config,
i18n,
viewConfig,
})
} else {
meta = await generateNotFoundMeta({ config, i18n })
}
} }
return meta return meta

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import type { MetaConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { formatDate } from '@payloadcms/ui/shared' import { formatDate } from '@payloadcms/ui/shared'
@@ -15,9 +16,9 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
}): Promise<Metadata> => { }): Promise<Metadata> => {
const { t } = i18n const { t } = i18n
let title: string = '' let metaToUse: MetaConfig = {
let description: string = '' ...(config.admin.meta || {}),
const keywords: string = '' }
const doc: any = {} // TODO: figure this out const doc: any = {} // TODO: figure this out
@@ -29,23 +30,30 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
const useAsTitle = collectionConfig?.admin?.useAsTitle || 'id' const useAsTitle = collectionConfig?.admin?.useAsTitle || 'id'
const entityLabel = getTranslation(collectionConfig.labels.singular, i18n) const entityLabel = getTranslation(collectionConfig.labels.singular, i18n)
const titleFromData = doc?.[useAsTitle] const titleFromData = doc?.[useAsTitle]
title = `${t('version:version')}${formattedCreatedAt ? ` - ${formattedCreatedAt}` : ''}${titleFromData ? ` - ${titleFromData}` : ''} - ${entityLabel}`
description = t('version:viewingVersion', { documentTitle: doc[useAsTitle], entityLabel }) metaToUse = {
...(config.admin.meta || {}),
description: t('version:viewingVersion', { documentTitle: doc[useAsTitle], entityLabel }),
title: `${t('version:version')}${formattedCreatedAt ? ` - ${formattedCreatedAt}` : ''}${titleFromData ? ` - ${titleFromData}` : ''} - ${entityLabel}`,
...(collectionConfig?.admin?.meta || {}),
...(collectionConfig?.admin?.components?.views?.edit?.version?.meta || {}),
}
} }
if (globalConfig) { if (globalConfig) {
const entityLabel = getTranslation(globalConfig.label, i18n) const entityLabel = getTranslation(globalConfig.label, i18n)
title = `${t('version:version')}${formattedCreatedAt ? ` - ${formattedCreatedAt}` : ''}${entityLabel}`
description = t('version:viewingVersionGlobal', { entityLabel }) metaToUse = {
...(config.admin.meta || {}),
description: t('version:viewingVersionGlobal', { entityLabel }),
title: `${t('version:version')}${formattedCreatedAt ? ` - ${formattedCreatedAt}` : ''}${entityLabel}`,
...(globalConfig?.admin?.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.version?.meta || {}),
}
} }
return meta({ return meta({
...(config.admin.meta || {}), ...metaToUse,
description,
keywords,
serverURL: config.serverURL, serverURL: config.serverURL,
title,
...(collectionConfig?.admin.meta || {}),
...(globalConfig?.admin.meta || {}),
}) })
} }

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import type { MetaConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
@@ -20,34 +21,40 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
? getTranslation(globalConfig.label, i18n) ? getTranslation(globalConfig.label, i18n)
: '' : ''
let title: string = '' let metaToUse: MetaConfig = {
let description: string = '' ...(config.admin.meta || {}),
const keywords: string = '' }
const data: any = {} // TODO: figure this out const data: any = {} // TODO: figure this out
if (collectionConfig) { if (collectionConfig) {
const useAsTitle = collectionConfig?.admin?.useAsTitle || 'id' const useAsTitle = collectionConfig?.admin?.useAsTitle || 'id'
const titleFromData = data?.[useAsTitle] const titleFromData = data?.[useAsTitle]
title = `${t('version:versions')}${titleFromData ? ` - ${titleFromData}` : ''} - ${entityLabel}`
description = t('version:viewingVersions', { metaToUse = {
documentTitle: data?.[useAsTitle], ...(config.admin.meta || {}),
entitySlug: collectionConfig.slug, description: t('version:viewingVersions', {
}) documentTitle: data?.[useAsTitle],
entitySlug: collectionConfig.slug,
}),
title: `${t('version:versions')}${titleFromData ? ` - ${titleFromData}` : ''} - ${entityLabel}`,
...(collectionConfig?.admin.meta || {}),
...(collectionConfig?.admin?.components?.views?.edit?.versions?.meta || {}),
}
} }
if (globalConfig) { if (globalConfig) {
title = `${t('version:versions')} - ${entityLabel}` metaToUse = {
description = t('version:viewingVersionsGlobal', { entitySlug: globalConfig.slug }) ...(config.admin.meta || {}),
description: t('version:viewingVersionsGlobal', { entitySlug: globalConfig.slug }),
title: `${t('version:versions')} - ${entityLabel}`,
...(globalConfig?.admin.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.versions?.meta || {}),
}
} }
return meta({ return meta({
...(config.admin.meta || {}), ...metaToUse,
description,
keywords,
serverURL: config.serverURL, serverURL: config.serverURL,
title,
...(collectionConfig?.admin.meta || {}),
...(globalConfig?.admin.meta || {}),
}) })
} }

View File

@@ -206,6 +206,7 @@ export type { RowLabel, RowLabelComponent } from './forms/RowLabel.js'
export type { export type {
AdminViewComponent, AdminViewComponent,
AdminViewConfig,
AdminViewProps, AdminViewProps,
EditViewProps, EditViewProps,
InitPageResult, InitPageResult,

View File

@@ -4,7 +4,7 @@ import type { Permissions } from '../../auth/index.js'
import type { ImportMap } from '../../bin/generateImportMap/index.js' import type { ImportMap } from '../../bin/generateImportMap/index.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js' import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { ClientConfig } from '../../config/client.js' import type { ClientConfig } from '../../config/client.js'
import type { Locale, PayloadComponent } from '../../config/types.js' import type { Locale, MetaConfig, PayloadComponent } from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js' import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { PayloadRequest } from '../../types/index.js' import type { PayloadRequest } from '../../types/index.js'
import type { LanguageOptions } from '../LanguageOptions.js' import type { LanguageOptions } from '../LanguageOptions.js'
@@ -14,6 +14,7 @@ export type AdminViewConfig = {
Component: AdminViewComponent Component: AdminViewComponent
/** Whether the path should be matched exactly or as a prefix */ /** Whether the path should be matched exactly or as a prefix */
exact?: boolean exact?: boolean
meta?: MetaConfig
path?: string path?: string
sensitive?: boolean sensitive?: boolean
strict?: boolean strict?: boolean

View File

@@ -24,6 +24,7 @@ import type {
GeneratePreviewURL, GeneratePreviewURL,
LabelFunction, LabelFunction,
LivePreviewConfig, LivePreviewConfig,
MetaConfig,
OpenGraphConfig, OpenGraphConfig,
PayloadComponent, PayloadComponent,
StaticLabel, StaticLabel,
@@ -329,10 +330,7 @@ export type CollectionAdminOptions = {
* Live preview options * Live preview options
*/ */
livePreview?: LivePreviewConfig livePreview?: LivePreviewConfig
meta?: { meta?: MetaConfig
description?: string
openGraph?: OpenGraphConfig
}
pagination?: { pagination?: {
defaultLimit?: number defaultLimit?: number
limits?: number[] limits?: number[]

View File

@@ -375,7 +375,9 @@ export type Endpoint = {
export type EditViewComponent = PayloadComponent<ServerSideEditViewProps> export type EditViewComponent = PayloadComponent<ServerSideEditViewProps>
export type EditViewConfig = export type EditViewConfig = {
meta?: MetaConfig
} & (
| { | {
Component: EditViewComponent Component: EditViewComponent
path?: string path?: string
@@ -393,6 +395,7 @@ export type EditViewConfig =
*/ */
tab?: DocumentTabConfig tab?: DocumentTabConfig
} }
)
export type ServerProps = { export type ServerProps = {
[key: string]: unknown [key: string]: unknown

View File

@@ -15,6 +15,7 @@ import type {
EntityDescriptionComponent, EntityDescriptionComponent,
GeneratePreviewURL, GeneratePreviewURL,
LivePreviewConfig, LivePreviewConfig,
MetaConfig,
OpenGraphConfig, OpenGraphConfig,
} from '../../config/types.js' } from '../../config/types.js'
import type { DBIdentifierName } from '../../database/types.js' import type { DBIdentifierName } from '../../database/types.js'
@@ -128,10 +129,7 @@ export type GlobalAdminOptions = {
* Live preview options * Live preview options
*/ */
livePreview?: LivePreviewConfig livePreview?: LivePreviewConfig
meta?: { meta?: MetaConfig
description?: string
openGraph?: OpenGraphConfig
}
/** /**
* Function to generate custom preview URL * Function to generate custom preview URL
*/ */

View File

@@ -1,18 +1,25 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { import {
customCollectionMetaTitle,
customCollectionParamViewPath, customCollectionParamViewPath,
customCollectionParamViewPathBase, customCollectionParamViewPathBase,
customDefaultTabMetaTitle,
customEditLabel, customEditLabel,
customNestedTabViewPath, customNestedTabViewPath,
customTabLabel, customTabLabel,
customTabViewPath, customTabViewPath,
customVersionsTabMetaTitle,
customViewMetaTitle,
} from '../shared.js' } from '../shared.js'
import { customViews2CollectionSlug } from '../slugs.js' import { customViews2CollectionSlug } from '../slugs.js'
export const CustomViews2: CollectionConfig = { export const CustomViews2: CollectionConfig = {
slug: customViews2CollectionSlug, slug: customViews2CollectionSlug,
admin: { admin: {
meta: {
title: customCollectionMetaTitle,
},
components: { components: {
views: { views: {
edit: { edit: {
@@ -29,6 +36,9 @@ export const CustomViews2: CollectionConfig = {
tab: { tab: {
label: customEditLabel, label: customEditLabel,
}, },
meta: {
title: customDefaultTabMetaTitle,
},
}, },
myCustomView: { myCustomView: {
Component: '/components/views/CustomTabLabel/index.js#CustomTabLabelView', Component: '/components/views/CustomTabLabel/index.js#CustomTabLabelView',
@@ -37,6 +47,9 @@ export const CustomViews2: CollectionConfig = {
label: customTabLabel, label: customTabLabel,
}, },
path: '/custom-tab-view', path: '/custom-tab-view',
meta: {
title: customViewMetaTitle,
},
}, },
myCustomViewWithCustomTab: { myCustomViewWithCustomTab: {
Component: '/components/views/CustomTabComponent/index.js#CustomTabComponentView', Component: '/components/views/CustomTabComponent/index.js#CustomTabComponentView',
@@ -52,9 +65,15 @@ export const CustomViews2: CollectionConfig = {
label: 'Custom Nested Tab View', label: 'Custom Nested Tab View',
}, },
path: customNestedTabViewPath, path: customNestedTabViewPath,
meta: {
title: 'Custom Nested Meta Title',
},
}, },
versions: { versions: {
Component: '/components/views/CustomVersions/index.js#CustomVersionsView', Component: '/components/views/CustomVersions/index.js#CustomVersionsView',
meta: {
title: customVersionsTabMetaTitle,
},
}, },
}, },
}, },

View File

@@ -18,7 +18,7 @@ export const CustomTabComponentClient: React.FC<{
const params = useParams() const params = useParams()
const baseRoute = (params.segments.slice(0, 2) as string[]).join('/') const baseRoute = (params.segments.slice(0, 3) as string[]).join('/')
return <Link href={`${adminRoute}/${baseRoute}${path}`}>Custom Tab Component</Link> return <Link href={`${adminRoute}/${baseRoute}${path}`}>Custom Tab Component</Link>
} }

View File

@@ -31,6 +31,7 @@ import {
customAdminRoutes, customAdminRoutes,
customNestedViewPath, customNestedViewPath,
customParamViewPath, customParamViewPath,
customRootViewMetaTitle,
customViewPath, customViewPath,
} from './shared.js' } from './shared.js'
export default buildConfigWithDefaults({ export default buildConfigWithDefaults({
@@ -60,6 +61,9 @@ export default buildConfigWithDefaults({
CustomMinimalView: { CustomMinimalView: {
Component: '/components/views/CustomMinimal/index.js#CustomMinimalView', Component: '/components/views/CustomMinimal/index.js#CustomMinimalView',
path: '/custom-minimal-view', path: '/custom-minimal-view',
meta: {
title: customRootViewMetaTitle,
},
}, },
CustomNestedView: { CustomNestedView: {
Component: '/components/views/CustomViewNested/index.js#CustomNestedView', Component: '/components/views/CustomViewNested/index.js#CustomNestedView',
@@ -97,7 +101,7 @@ export default buildConfigWithDefaults({
description: 'This is a custom OG description', description: 'This is a custom OG description',
title: 'This is a custom OG title', title: 'This is a custom OG title',
}, },
titleSuffix: '- Custom CMS', titleSuffix: '- Custom Title Suffix',
}, },
routes: customAdminRoutes, routes: customAdminRoutes,
}, },

View File

@@ -21,14 +21,19 @@ import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { import {
customAdminRoutes, customAdminRoutes,
customCollectionMetaTitle,
customDefaultTabMetaTitle,
customEditLabel, customEditLabel,
customNestedTabViewPath, customNestedTabViewPath,
customNestedTabViewTitle, customNestedTabViewTitle,
customNestedViewPath, customNestedViewPath,
customNestedViewTitle, customNestedViewTitle,
customRootViewMetaTitle,
customTabLabel, customTabLabel,
customTabViewPath, customTabViewPath,
customTabViewTitle, customTabViewTitle,
customVersionsTabMetaTitle,
customViewMetaTitle,
customViewPath, customViewPath,
customViewTitle, customViewTitle,
slugPluralLabel, slugPluralLabel,
@@ -120,102 +125,154 @@ describe('admin1', () => {
}) })
describe('metadata', () => { describe('metadata', () => {
test('should render custom page title suffix', async () => { describe('root title and description', () => {
await page.goto(`${serverURL}/admin`) test('should render custom page title suffix', async () => {
await expect(page.title()).resolves.toMatch(/- Custom CMS$/) await page.goto(`${serverURL}/admin`)
await expect(page.title()).resolves.toMatch(/- Custom Title Suffix$/)
})
test('should render custom meta description from root config', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
/This is a custom meta description/,
)
})
test('should render custom meta description from collection config', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug))
await page.locator('.collection-list .table a').first().click()
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
/This is a custom meta description for posts/,
)
})
test('should fallback to root meta for custom root views', async () => {
await page.goto(`${serverURL}/admin/custom-default-view`)
await expect(page.title()).resolves.toMatch(/- Custom Title Suffix$/)
})
test('should render custom meta title from custom root views', async () => {
await page.goto(`${serverURL}/admin/custom-minimal-view`)
const pattern = new RegExp(`^${customRootViewMetaTitle}`)
await expect(page.title()).resolves.toMatch(pattern)
})
}) })
test('should render custom meta description from root config', async () => { describe('favicons', () => {
await page.goto(`${serverURL}/admin`) test('should render custom favicons', async () => {
await expect(page.locator('meta[name="description"]')).toHaveAttribute( await page.goto(postsUrl.admin)
'content', const favicons = page.locator('link[rel="icon"]')
/This is a custom meta description/,
) await expect(favicons).toHaveCount(2)
await expect(favicons.nth(0)).toHaveAttribute(
'href',
/\/custom-favicon-dark(\.[a-z\d]+)?\.png/,
)
await expect(favicons.nth(1)).toHaveAttribute('media', '(prefers-color-scheme: dark)')
await expect(favicons.nth(1)).toHaveAttribute(
'href',
/\/custom-favicon-light(\.[a-z\d]+)?\.png/,
)
})
}) })
test('should render custom meta description from collection config', async () => { describe('og meta', () => {
await page.goto(postsUrl.collection(postsCollectionSlug)) test('should render custom og:title from root config', async () => {
await page.locator('.collection-list .table a').first().click() await page.goto(`${serverURL}/admin`)
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
'content',
/This is a custom OG title/,
)
})
await expect(page.locator('meta[name="description"]')).toHaveAttribute( test('should render custom og:description from root config', async () => {
'content', await page.goto(`${serverURL}/admin`)
/This is a custom meta description for posts/, await expect(page.locator('meta[property="og:description"]')).toHaveAttribute(
) 'content',
/This is a custom OG description/,
)
})
test('should render custom og:title from collection config', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug))
await page.locator('.collection-list .table a').first().click()
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
'content',
/This is a custom OG title for posts/,
)
})
test('should render custom og:description from collection config', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug))
await page.locator('.collection-list .table a').first().click()
await expect(page.locator('meta[property="og:description"]')).toHaveAttribute(
'content',
/This is a custom OG description for posts/,
)
})
test('should render og:image with dynamic URL', async () => {
await page.goto(postsUrl.admin)
const encodedOGDescription = encodeURIComponent('This is a custom OG description')
const encodedOGTitle = encodeURIComponent('This is a custom OG title')
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
'content',
new RegExp(`/api/og\\?description=${encodedOGDescription}&title=${encodedOGTitle}`),
)
})
test('should render twitter:image with dynamic URL', async () => {
await page.goto(postsUrl.admin)
const encodedOGDescription = encodeURIComponent('This is a custom OG description')
const encodedOGTitle = encodeURIComponent('This is a custom OG title')
await expect(page.locator('meta[name="twitter:image"]')).toHaveAttribute(
'content',
new RegExp(`/api/og\\?description=${encodedOGDescription}&title=${encodedOGTitle}`),
)
})
}) })
test('should render custom favicons', async () => { describe('document meta', () => {
await page.goto(postsUrl.admin) test('should render custom meta title from collection config', async () => {
const favicons = page.locator('link[rel="icon"]') await page.goto(customViewsURL.list)
const pattern = new RegExp(`^${customCollectionMetaTitle}`)
await expect(page.title()).resolves.toMatch(pattern)
})
await expect(favicons).toHaveCount(2) test('should render custom meta title from default edit view', async () => {
await expect(favicons.nth(0)).toHaveAttribute( await navigateToDoc(page, customViewsURL)
'href', const pattern = new RegExp(`^${customDefaultTabMetaTitle}`)
/\/custom-favicon-dark(\.[a-z\d]+)?\.png/, await expect(page.title()).resolves.toMatch(pattern)
) })
await expect(favicons.nth(1)).toHaveAttribute('media', '(prefers-color-scheme: dark)')
await expect(favicons.nth(1)).toHaveAttribute(
'href',
/\/custom-favicon-light(\.[a-z\d]+)?\.png/,
)
})
test('should render custom og:title from root config', async () => { test('should render custom meta title from nested edit view', async () => {
await page.goto(`${serverURL}/admin`) await navigateToDoc(page, customViewsURL)
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( await page.goto(`${page.url()}/versions`)
'content', const pattern = new RegExp(`^${customVersionsTabMetaTitle}`)
/This is a custom OG title/, await expect(page.title()).resolves.toMatch(pattern)
) })
})
test('should render custom og:description from root config', async () => { test('should render custom meta title from nested custom view', async () => {
await page.goto(`${serverURL}/admin`) await navigateToDoc(page, customViewsURL)
await expect(page.locator('meta[property="og:description"]')).toHaveAttribute( await page.goto(`${page.url()}/custom-tab-view`)
'content', const pattern = new RegExp(`^${customViewMetaTitle}`)
/This is a custom OG description/, await expect(page.title()).resolves.toMatch(pattern)
) })
})
test('should render custom og:title from collection config', async () => { test('should render fallback meta title from nested custom view', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug)) await navigateToDoc(page, customViewsURL)
await page.locator('.collection-list .table a').first().click() await page.goto(`${page.url()}${customTabViewPath}`)
const pattern = new RegExp(`^${customCollectionMetaTitle}`)
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( await expect(page.title()).resolves.toMatch(pattern)
'content', })
/This is a custom OG title for posts/,
)
})
test('should render custom og:description from collection config', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug))
await page.locator('.collection-list .table a').first().click()
await expect(page.locator('meta[property="og:description"]')).toHaveAttribute(
'content',
/This is a custom OG description for posts/,
)
})
test('should render og:image with dynamic URL', async () => {
await page.goto(postsUrl.admin)
const encodedOGDescription = encodeURIComponent('This is a custom OG description')
const encodedOGTitle = encodeURIComponent('This is a custom OG title')
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
'content',
new RegExp(`/api/og\\?description=${encodedOGDescription}&title=${encodedOGTitle}`),
)
})
test('should render twitter:image with dynamic URL', async () => {
await page.goto(postsUrl.admin)
const encodedOGDescription = encodeURIComponent('This is a custom OG description')
const encodedOGTitle = encodeURIComponent('This is a custom OG title')
await expect(page.locator('meta[name="twitter:image"]')).toHaveAttribute(
'content',
new RegExp(`/api/og\\?description=${encodedOGDescription}&title=${encodedOGTitle}`),
)
}) })
}) })

View File

@@ -23,6 +23,7 @@ export const customEditLabel = 'Custom Edit Label'
export const customTabLabel = 'Custom Tab Label' export const customTabLabel = 'Custom Tab Label'
export const customTabViewPath = '/custom-tab-component' export const customTabViewPath = '/custom-tab-component'
export const customTabViewTitle = 'Custom View With Tab Component' export const customTabViewTitle = 'Custom View With Tab Component'
export const customTabLabelViewTitle = 'Custom Tab Label View' export const customTabLabelViewTitle = 'Custom Tab Label View'
@@ -31,6 +32,16 @@ export const customTabViewComponentTitle = 'Custom View With Tab Component'
export const customNestedTabViewPath = `${customTabViewPath}/nested-view` export const customNestedTabViewPath = `${customTabViewPath}/nested-view`
export const customCollectionMetaTitle = 'Custom Meta Title'
export const customRootViewMetaTitle = 'Custom Root View Meta Title'
export const customDefaultTabMetaTitle = 'Custom Default Tab Meta Title'
export const customVersionsTabMetaTitle = 'Custom Versions Tab Meta Title'
export const customViewMetaTitle = 'Custom Tab Meta Title'
export const customNestedTabViewTitle = 'Custom Nested Tab View' export const customNestedTabViewTitle = 'Custom Nested Tab View'
export const customCollectionParamViewPathBase = '/custom-param' export const customCollectionParamViewPathBase = '/custom-param'

View File

@@ -37,7 +37,7 @@
], ],
"paths": { "paths": {
"@payload-config": [ "@payload-config": [
"./test/_community/config.ts" "./test/admin/config.ts"
], ],
"@payloadcms/live-preview": [ "@payloadcms/live-preview": [
"./packages/live-preview/src" "./packages/live-preview/src"