diff --git a/packages/next/src/utilities/meta.ts b/packages/next/src/utilities/meta.ts index 8ef77660f0..fea51cbde5 100644 --- a/packages/next/src/utilities/meta.ts +++ b/packages/next/src/utilities/meta.ts @@ -45,7 +45,7 @@ export const meta = async (args: { serverURL: string } & MetaConfig): Promise { - 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, + } } diff --git a/packages/next/src/views/Document/getMetaBySegment.tsx b/packages/next/src/views/Document/getMetaBySegment.tsx index d5afad7e50..06eaf203e3 100644 --- a/packages/next/src/views/Document/getMetaBySegment.tsx +++ b/packages/next/src/views/Document/getMetaBySegment.tsx @@ -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[0], ) => Promise @@ -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') { - fn = apiMeta + // `/:collection/:id/:view` + if (params.segments.length === 4) { + 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` - if (params.segments.length === 4 && params.segments[3] === 'preview') { - fn = livePreviewMeta - } - - // `/:id/versions` - if (params.segments.length === 4 && params.segments[3] === 'versions') { - fn = versionsMeta - } - - // `/:id/versions/:version` - if (params.segments.length === 5 && params.segments[3] === 'versions') { - fn = versionMeta + // `/: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') { - fn = apiMeta + // `/:global/:view` + if (params.segments?.length === 3) { + 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` - 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` + // `/: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 }) diff --git a/packages/next/src/views/Document/getViewsFromConfig.tsx b/packages/next/src/views/Document/getViewsFromConfig.tsx index a84e1b573a..5f41d2398e 100644 --- a/packages/next/src/views/Document/getViewsFromConfig.tsx +++ b/packages/next/src/views/Document/getViewsFromConfig.tsx @@ -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 DefaultView: ViewFromConfig /** * 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 + viewKey: string } | null => { // Conditionally import and lazy load the default view let DefaultView: ViewFromConfig = null let CustomView: ViewFromConfig = null let ErrorView: ViewFromConfig = 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: { - CustomView = { - payloadComponent: getCustomViewByKey(views, 'default'), - } - DefaultView = { - Component: DefaultEditView, + 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,15 +347,23 @@ export const getViewsFromConfig = ({ .filter(Boolean) .join('/') - CustomView = { - payloadComponent: getCustomViewByRoute({ + const { Component: CustomViewComponent, viewKey: customViewKey } = + getCustomViewByRoute({ baseRoute, currentRoute, views, - }), - } - DefaultView = { - Component: DefaultEditView, + }) + + if (customViewKey) { + viewKey = customViewKey + + CustomView = { + payloadComponent: CustomViewComponent, + } + } else { + DefaultView = { + Component: DefaultEditView, + } } } else { ErrorView = { @@ -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, } } diff --git a/packages/next/src/views/Edit/meta.ts b/packages/next/src/views/Edit/meta.ts index ab58ac0a77..b49723102a 100644 --- a/packages/next/src/views/Edit/meta.ts +++ b/packages/next/src/views/Edit/meta.ts @@ -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 => { 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, - }, - ...(collectionConfig?.admin.meta || {}), - ...(globalConfig?.admin.meta || {}), + ...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, }) } diff --git a/packages/next/src/views/LivePreview/meta.ts b/packages/next/src/views/LivePreview/meta.ts index 26deaf3118..3610d4969b 100644 --- a/packages/next/src/views/LivePreview/meta.ts +++ b/packages/next/src/views/LivePreview/meta.ts @@ -17,4 +17,5 @@ export const generateMetadata: GenerateEditViewMetadata = async ({ globalConfig, i18n, isEditing, + view: 'livePreview', }) diff --git a/packages/next/src/views/Root/generateCustomViewMetadata.ts b/packages/next/src/views/Root/generateCustomViewMetadata.ts new file mode 100644 index 0000000000..f39d201183 --- /dev/null +++ b/packages/next/src/views/Root/generateCustomViewMetadata.ts @@ -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 => { + 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 || {}), + }, + }) +} diff --git a/packages/next/src/views/Root/getCustomViewByRoute.ts b/packages/next/src/views/Root/getCustomViewByRoute.ts new file mode 100644 index 0000000000..ba2e64831b --- /dev/null +++ b/packages/next/src/views/Root/getCustomViewByRoute.ts @@ -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, + } +} diff --git a/packages/next/src/views/Root/getCustomViewByRoute.tsx b/packages/next/src/views/Root/getCustomViewByRoute.tsx deleted file mode 100644 index e3773a6b99..0000000000 --- a/packages/next/src/views/Root/getCustomViewByRoute.tsx +++ /dev/null @@ -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, - } -} diff --git a/packages/next/src/views/Root/getViewFromConfig.tsx b/packages/next/src/views/Root/getViewFromConfig.ts similarity index 94% rename from packages/next/src/views/Root/getViewFromConfig.tsx rename to packages/next/src/views/Root/getViewFromConfig.ts index fd094e6fcc..c7ef77d69f 100644 --- a/packages/next/src/views/Root/getViewFromConfig.tsx +++ b/packages/next/src/views/Root/getViewFromConfig.ts @@ -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 { diff --git a/packages/next/src/views/Root/meta.ts b/packages/next/src/views/Root/meta.ts index 6e5db39ba2..a7b10faee7 100644 --- a/packages/next/src/views/Root/meta.ts +++ b/packages/next/src/views/Root/meta.ts @@ -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,7 +133,22 @@ export const generatePageMetadata = async ({ config: configPromise, params }: Ar } 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 diff --git a/packages/next/src/views/Version/meta.ts b/packages/next/src/views/Version/meta.ts index 0bb8c02777..77e7d0859c 100644 --- a/packages/next/src/views/Version/meta.ts +++ b/packages/next/src/views/Version/meta.ts @@ -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 => { 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 || {}), }) } diff --git a/packages/next/src/views/Versions/meta.ts b/packages/next/src/views/Versions/meta.ts index 4ba4090bdd..ca973908bf 100644 --- a/packages/next/src/views/Versions/meta.ts +++ b/packages/next/src/views/Versions/meta.ts @@ -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', { - documentTitle: data?.[useAsTitle], - entitySlug: collectionConfig.slug, - }) + + 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 || {}), }) } diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index c1082b6537..a86e121ecc 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -206,6 +206,7 @@ export type { RowLabel, RowLabelComponent } from './forms/RowLabel.js' export type { AdminViewComponent, + AdminViewConfig, AdminViewProps, EditViewProps, InitPageResult, diff --git a/packages/payload/src/admin/views/types.ts b/packages/payload/src/admin/views/types.ts index a7f0985d14..183e255a1c 100644 --- a/packages/payload/src/admin/views/types.ts +++ b/packages/payload/src/admin/views/types.ts @@ -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 diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index bd7f1184e0..0c528c2edb 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -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[] diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index dfb3e765a5..cbc4eed99c 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -375,7 +375,9 @@ export type Endpoint = { export type EditViewComponent = PayloadComponent -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 diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index c13536a4d0..0b1c6f0506 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -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 */ diff --git a/test/admin/collections/CustomViews2.ts b/test/admin/collections/CustomViews2.ts index a001e9420d..a88a033c54 100644 --- a/test/admin/collections/CustomViews2.ts +++ b/test/admin/collections/CustomViews2.ts @@ -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, + }, }, }, }, diff --git a/test/admin/components/CustomTabComponent/client.tsx b/test/admin/components/CustomTabComponent/client.tsx index b774b679a7..ab1305568d 100644 --- a/test/admin/components/CustomTabComponent/client.tsx +++ b/test/admin/components/CustomTabComponent/client.tsx @@ -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 Custom Tab Component } diff --git a/test/admin/config.ts b/test/admin/config.ts index e9fdcb4718..428466b335 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -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, }, diff --git a/test/admin/e2e/1/e2e.spec.ts b/test/admin/e2e/1/e2e.spec.ts index a8d3750e3e..36dddb5fb5 100644 --- a/test/admin/e2e/1/e2e.spec.ts +++ b/test/admin/e2e/1/e2e.spec.ts @@ -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,102 +125,154 @@ describe('admin1', () => { }) describe('metadata', () => { - test('should render custom page title suffix', async () => { - await page.goto(`${serverURL}/admin`) - await expect(page.title()).resolves.toMatch(/- Custom CMS$/) + 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 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 () => { - await page.goto(`${serverURL}/admin`) - await expect(page.locator('meta[name="description"]')).toHaveAttribute( - 'content', - /This is a custom meta description/, - ) + describe('favicons', () => { + test('should render custom favicons', async () => { + await page.goto(postsUrl.admin) + const favicons = page.locator('link[rel="icon"]') + + 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 () => { - await page.goto(postsUrl.collection(postsCollectionSlug)) - await page.locator('.collection-list .table a').first().click() + 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( + 'content', + /This is a custom OG title/, + ) + }) - await expect(page.locator('meta[name="description"]')).toHaveAttribute( - 'content', - /This is a custom meta description for posts/, - ) + test('should render custom og:description from root config', async () => { + await page.goto(`${serverURL}/admin`) + 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 () => { - await page.goto(postsUrl.admin) - const favicons = page.locator('link[rel="icon"]') + 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) + }) - 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 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 og:title from root config', async () => { - await page.goto(`${serverURL}/admin`) - await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( - 'content', - /This is a custom OG title/, - ) - }) + 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 og:description from root config', async () => { - await page.goto(`${serverURL}/admin`) - await expect(page.locator('meta[property="og:description"]')).toHaveAttribute( - 'content', - /This is a custom OG description/, - ) - }) + 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 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 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) + }) }) }) diff --git a/test/admin/shared.ts b/test/admin/shared.ts index 13350be030..41e687bc73 100644 --- a/test/admin/shared.ts +++ b/test/admin/shared.ts @@ -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' diff --git a/tsconfig.json b/tsconfig.json index ae27597fb0..fdef1fc52c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,7 @@ ], "paths": { "@payload-config": [ - "./test/_community/config.ts" + "./test/admin/config.ts" ], "@payloadcms/live-preview": [ "./packages/live-preview/src"