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
}
const metaTitle = `${title} ${titleSuffix}`
const metaTitle = [title, titleSuffix].filter(Boolean).join(' ')
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)
: ''
const metaTitle = `API - ${entityLabel}`
const description = `API - ${entityLabel}`
return Promise.resolve(
meta({
...(config.admin.meta || {}),
description,
description: `API - ${entityLabel}`,
keywords: 'API',
serverURL: config.serverURL,
title: metaTitle,
title: `API - ${entityLabel}`,
...(collectionConfig
? {
...(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:
| SanitizedCollectionConfig['admin']['components']['views']
| SanitizedGlobalConfig['admin']['components']['views']
}): EditViewComponent => {
if (typeof views?.edit === 'object' && typeof views?.edit !== 'function') {
const foundViewConfig = Object.entries(views.edit).find(([, view]) => {
if (typeof view === 'object' && typeof view !== 'function' && 'path' in view) {
}): {
Component: EditViewComponent
viewKey?: string
} => {
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}`
return isPathMatchingRoute({
const isMatching = isPathMatchingRoute({
currentRoute,
exact: true,
path: viewPath,
})
if (isMatching) {
viewKey = key
}
return isMatching
}
return false
})?.[1]
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 { SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
import type { EditConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
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 { generateMetadata as versionMeta } from '../Version/meta.js'
import { generateMetadata as versionsMeta } from '../Versions/meta.js'
import { getViewsFromConfig } from './getViewsFromConfig.js'
export type GenerateEditViewMetadata = (
args: {
collectionConfig?: SanitizedCollectionConfig | null
globalConfig?: SanitizedGlobalConfig | null
view?: keyof EditConfig
} & Parameters<GenerateViewMetadata>[0],
) => Promise<Metadata>
@@ -36,54 +38,71 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
isGlobal || Boolean(isCollection && segments?.length > 2 && segments[2] !== 'create')
if (isCollection) {
// `/:id`
// `/:collection/:id`
if (params.segments.length === 3) {
fn = editMeta
}
// `/:id/api`
if (params.segments.length === 4 && params.segments[3] === 'api') {
// `/:collection/:id/:view`
if (params.segments.length === 4) {
switch (params.segments[3]) {
case 'api':
// `/:collection/:id/api`
fn = apiMeta
}
// `/:id/preview`
if (params.segments.length === 4 && params.segments[3] === 'preview') {
break
case 'preview':
// `/:collection/:id/preview`
fn = livePreviewMeta
}
// `/:id/versions`
if (params.segments.length === 4 && params.segments[3] === 'versions') {
break
case 'versions':
// `/:collection/:id/versions`
fn = versionsMeta
break
default:
break
}
}
// `/:id/versions/:version`
if (params.segments.length === 5 && params.segments[3] === 'versions') {
// `/:collection/:id/:slug-1/:slug-2`
if (params.segments.length === 5) {
switch (params.segments[3]) {
case 'versions':
// `/:collection/:id/versions/:version`
fn = versionMeta
break
default:
break
}
}
}
if (isGlobal) {
// `/:slug`
// `/:global`
if (params.segments?.length === 2) {
fn = editMeta
}
// `/:slug/api`
if (params.segments?.length === 3 && params.segments[2] === 'api') {
// `/:global/:view`
if (params.segments?.length === 3) {
switch (params.segments[2]) {
case 'api':
// `/:global/api`
fn = apiMeta
}
// `/:slug/preview`
if (params.segments?.length === 3 && params.segments[2] === 'preview') {
break
case 'preview':
// `/:global/preview`
fn = livePreviewMeta
}
// `/:slug/versions`
if (params.segments?.length === 3 && params.segments[2] === 'versions') {
break
case 'versions':
// `/:global/versions`
fn = versionsMeta
break
default:
break
}
}
// `/:slug/versions/:version`
// `/:global/versions/:version`
if (params.segments?.length === 4 && params.segments[2] === 'versions') {
fn = versionMeta
}
@@ -101,6 +120,31 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
i18n,
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 })

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import { generateNotFoundMeta } from '../NotFound/meta.js'
import { generateResetPasswordMetadata } from '../ResetPassword/index.js'
import { generateUnauthorizedMetadata } from '../Unauthorized/index.js'
import { generateVerifyMetadata } from '../Verify/index.js'
import { generateCustomViewMetadata } from './generateCustomViewMetadata.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
const oneSegmentMeta = {
'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 currentRoute = `/${segments.join('/')}`
const [segmentOne, segmentTwo] = segments
const isGlobal = segmentOne === 'globals'
@@ -130,8 +133,23 @@ export const generatePageMetadata = async ({ config: configPromise, params }: Ar
}
if (!meta) {
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
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import type { MetaConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { formatDate } from '@payloadcms/ui/shared'
@@ -15,9 +16,9 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
}): Promise<Metadata> => {
const { t } = i18n
let title: string = ''
let description: string = ''
const keywords: string = ''
let metaToUse: MetaConfig = {
...(config.admin.meta || {}),
}
const doc: any = {} // TODO: figure this out
@@ -29,23 +30,30 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
const useAsTitle = collectionConfig?.admin?.useAsTitle || 'id'
const entityLabel = getTranslation(collectionConfig.labels.singular, i18n)
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) {
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({
...(config.admin.meta || {}),
description,
keywords,
...metaToUse,
serverURL: config.serverURL,
title,
...(collectionConfig?.admin.meta || {}),
...(globalConfig?.admin.meta || {}),
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,14 +21,19 @@ import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import {
customAdminRoutes,
customCollectionMetaTitle,
customDefaultTabMetaTitle,
customEditLabel,
customNestedTabViewPath,
customNestedTabViewTitle,
customNestedViewPath,
customNestedViewTitle,
customRootViewMetaTitle,
customTabLabel,
customTabViewPath,
customTabViewTitle,
customVersionsTabMetaTitle,
customViewMetaTitle,
customViewPath,
customViewTitle,
slugPluralLabel,
@@ -120,9 +125,10 @@ describe('admin1', () => {
})
describe('metadata', () => {
describe('root title and description', () => {
test('should render custom page title suffix', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.title()).resolves.toMatch(/- Custom CMS$/)
await expect(page.title()).resolves.toMatch(/- Custom Title Suffix$/)
})
test('should render custom meta description from root config', async () => {
@@ -143,6 +149,19 @@ describe('admin1', () => {
)
})
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)
})
})
describe('favicons', () => {
test('should render custom favicons', async () => {
await page.goto(postsUrl.admin)
const favicons = page.locator('link[rel="icon"]')
@@ -158,7 +177,9 @@ describe('admin1', () => {
/\/custom-favicon-light(\.[a-z\d]+)?\.png/,
)
})
})
describe('og meta', () => {
test('should render custom og:title from root config', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
@@ -219,6 +240,42 @@ describe('admin1', () => {
})
})
describe('document meta', () => {
test('should render custom meta title from collection config', async () => {
await page.goto(customViewsURL.list)
const pattern = new RegExp(`^${customCollectionMetaTitle}`)
await expect(page.title()).resolves.toMatch(pattern)
})
test('should render custom meta title from default edit view', async () => {
await navigateToDoc(page, customViewsURL)
const pattern = new RegExp(`^${customDefaultTabMetaTitle}`)
await expect(page.title()).resolves.toMatch(pattern)
})
test('should render custom meta title from nested edit view', async () => {
await navigateToDoc(page, customViewsURL)
await page.goto(`${page.url()}/versions`)
const pattern = new RegExp(`^${customVersionsTabMetaTitle}`)
await expect(page.title()).resolves.toMatch(pattern)
})
test('should render custom meta title from nested custom view', async () => {
await navigateToDoc(page, customViewsURL)
await page.goto(`${page.url()}/custom-tab-view`)
const pattern = new RegExp(`^${customViewMetaTitle}`)
await expect(page.title()).resolves.toMatch(pattern)
})
test('should render fallback meta title from nested custom view', async () => {
await navigateToDoc(page, customViewsURL)
await page.goto(`${page.url()}${customTabViewPath}`)
const pattern = new RegExp(`^${customCollectionMetaTitle}`)
await expect(page.title()).resolves.toMatch(pattern)
})
})
})
describe('theme', () => {
test('should render light theme by default', async () => {
await page.goto(postsUrl.admin)

View File

@@ -23,6 +23,7 @@ export const customEditLabel = 'Custom Edit Label'
export const customTabLabel = 'Custom Tab Label'
export const customTabViewPath = '/custom-tab-component'
export const customTabViewTitle = 'Custom View With Tab Component'
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 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 customCollectionParamViewPathBase = '/custom-param'

View File

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