feat(next): root admin (#7276)

This commit is contained in:
Jacob Fletcher
2024-07-23 13:44:44 -04:00
committed by GitHub
parent b9cf6c73a9
commit 863abc0e6b
72 changed files with 2702 additions and 149 deletions

View File

@@ -292,6 +292,7 @@ jobs:
- access-control
- admin__e2e__1
- admin__e2e__2
- admin-root
- auth
- field-error-states
- fields-relationship

View File

@@ -169,19 +169,32 @@ The following options are available:
| Option | Default route | Description |
| ------------------ | ----------------------- | ------------------------------------- |
| `admin` | `/admin` | The Admin Panel itself. |
| `admin` | `/admin` | The Admin Panel itself. |
| `api` | `/api` | The [REST API](../rest-api/overview) base path. |
| `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. |
| `graphQLPlayground`| `/graphql-playground` | The GraphQL Playground. |
<Banner type="warning">
<strong>Warning:</strong>
Changing Root-level Routes _after_ your project was generated will also require you to manually update the corresponding directories in your project. For example, changing `routes.admin` will require you to rename the `(payload)/admin` directory in your project to match the new route. [More details](#project-structure).
</Banner>
<Banner type="success">
<strong>Tip:</strong>
You can easily add _new_ routes to the Admin Panel through the `endpoints` property of the Payload Config. See [Custom Endpoints](../rest-api/overview#custom-endpoints) for more information.
You can easily add _new_ routes to the Admin Panel through [Custom Endpoints](../rest-api/overview#custom-endpoints) and [Custom Views](./views).
</Banner>
#### Customizing Root-level Routes
You can change the Root-level Routes as needed, such as to mount the Admin Panel at the root of your application.
Changing Root-level Routes also requires a change to [Project Structure](#project-structure) to match the new route. For example, if you set `routes.admin` to `/`, you would need to completely remove the `admin` directory from the project structure:
```plaintext
app/
├─ (payload)/
├── [[...segments]]/
├──── ...
```
<Banner type="warning">
<strong>Note:</strong>
If you set Root-level Routes _before_ auto-generating the Admin Panel, your [Project Structure](#project-structure) will already be set up correctly.
</Banner>
### Admin-level Routes

View File

@@ -6,7 +6,7 @@ desc: Checkbox field types allow the developer to save a boolean value in the da
keywords: checkbox, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Checkbox Field type saves a boolean in the database.
The Checkbox Field saves a boolean in the database.
<LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/checkbox.png"

View File

@@ -6,7 +6,7 @@ desc: With the Collapsible field, you can place fields within a collapsible layo
keywords: row, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Collapsible field is presentational-only and only affects the Admin Panel. By using it, you can place fields within a nice layout component that can be collapsed / expanded.
The Collapsible Field is presentational-only and only affects the Admin Panel. By using it, you can place fields within a nice layout component that can be collapsed / expanded.
<LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/collapsible.png"

View File

@@ -6,7 +6,7 @@ desc: The Relationship field provides the ability to relate documents together.
keywords: relationship, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Relationship field is one of the most powerful fields Payload features. It provides for the ability to easily relate documents together.
The Relationship Field is one of the most powerful fields Payload features. It provides for the ability to easily relate documents together.
<LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/relationship.png"

View File

@@ -2,6 +2,7 @@
import type { SanitizedConfig } from 'payload'
import { useSearchParams } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import { useParams, usePathname } from 'next/navigation.js'
import React from 'react'
@@ -38,7 +39,12 @@ export const DocumentTabLink: React.FC<{
const [entityType, entitySlug, segmentThree, segmentFour, ...rest] = params.segments || []
const isCollection = entityType === 'collections'
let docPath = `${adminRoute}/${isCollection ? 'collections' : 'globals'}/${entitySlug}`
let docPath = formatAdminURL({
adminRoute,
path: `/${isCollection ? 'collections' : 'globals'}/${entitySlug}`,
})
if (isCollection && segmentThree) {
// doc ID
docPath += `/${segmentThree}`

View File

@@ -12,7 +12,7 @@ import {
useNav,
useTranslation,
} from '@payloadcms/ui'
import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
import { EntityType, formatAdminURL, groupNavItems } from '@payloadcms/ui/shared'
import LinkWithDefault from 'next/link.js'
import React, { Fragment } from 'react'
@@ -25,7 +25,7 @@ export const DefaultNavClient: React.FC = () => {
const {
collections,
globals,
routes: { admin },
routes: { admin: adminRoute },
} = useConfig()
const { i18n } = useTranslation()
@@ -69,13 +69,13 @@ export const DefaultNavClient: React.FC = () => {
let id: string
if (type === EntityType.collection) {
href = `${admin}/collections/${entity.slug}`
href = formatAdminURL({ adminRoute, path: `/collections/${entity.slug}` })
entityLabel = getTranslation(entity.labels.plural, i18n)
id = `nav-${entity.slug}`
}
if (type === EntityType.global) {
href = `${admin}/globals/${entity.slug}`
href = formatAdminURL({ adminRoute, path: `/globals/${entity.slug}` })
entityLabel = getTranslation(entity.label, i18n)
id = `nav-global-${entity.slug}`
}

View File

@@ -21,7 +21,8 @@ export const handleAdminPage = ({
route: string
}) => {
if (isAdminRoute(route, adminRoute)) {
const routeSegments = route.replace(adminRoute, '').split('/').filter(Boolean)
const baseAdminRoute = adminRoute && adminRoute !== '/' ? route.replace(adminRoute, '') : route
const routeSegments = baseAdminRoute.split('/').filter(Boolean)
const [entityType, entitySlug, createOrID] = routeSegments
const collectionSlug = entityType === 'collections' ? entitySlug : undefined
const globalSlug = entityType === 'globals' ? entitySlug : undefined
@@ -56,4 +57,6 @@ export const handleAdminPage = ({
globalConfig,
}
}
return {}
}

View File

@@ -1,3 +1,4 @@
import { formatAdminURL } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import * as qs from 'qs-esm'
@@ -30,7 +31,7 @@ export const handleAuthRedirect = ({
: undefined,
)
const adminLoginRoute = `${adminRoute}${loginRouteFromConfig}`
const adminLoginRoute = formatAdminURL({ adminRoute, path: loginRouteFromConfig })
const customLoginRoute =
typeof redirectUnauthenticatedUser === 'string' ? redirectUnauthenticatedUser : undefined

View File

@@ -3,7 +3,7 @@ import type { Permissions, ServerProps, VisibleEntities } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { Button, Card, Gutter, SetStepNav, SetViewActions } from '@payloadcms/ui'
import { EntityType, WithServerSideProps } from '@payloadcms/ui/shared'
import { EntityType, WithServerSideProps, formatAdminURL } from '@payloadcms/ui/shared'
import React, { Fragment } from 'react'
import './index.scss'
@@ -100,19 +100,31 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
if (type === EntityType.collection) {
title = getTranslation(entity.labels.plural, i18n)
buttonAriaLabel = t('general:showAllLabel', { label: title })
href = `${adminRoute}/collections/${entity.slug}`
createHREF = `${adminRoute}/collections/${entity.slug}/create`
href = formatAdminURL({ adminRoute, path: `/collections/${entity.slug}` })
createHREF = formatAdminURL({
adminRoute,
path: `/collections/${entity.slug}/create`,
})
hasCreatePermission =
permissions?.collections?.[entity.slug]?.create?.permission
}
if (type === EntityType.global) {
title = getTranslation(entity.label, i18n)
buttonAriaLabel = t('general:editLabel', {
label: getTranslation(entity.label, i18n),
})
href = `${adminRoute}/globals/${entity.slug}`
href = formatAdminURL({
adminRoute,
path: `/globals/${entity.slug}`,
})
}
return (

View File

@@ -126,7 +126,12 @@ export const getViewsFromConfig = ({
}
default: {
const baseRoute = [adminRoute, 'collections', collectionSlug, segment3]
const baseRoute = [
adminRoute !== '/' && adminRoute,
'collections',
collectionSlug,
segment3,
]
.filter(Boolean)
.join('/')
@@ -155,7 +160,12 @@ export const getViewsFromConfig = ({
ErrorView = UnauthorizedView
}
} else {
const baseRoute = [adminRoute, collectionEntity, collectionSlug, segment3]
const baseRoute = [
adminRoute !== '/' && adminRoute,
collectionEntity,
collectionSlug,
segment3,
]
.filter(Boolean)
.join('/')
@@ -260,7 +270,9 @@ export const getViewsFromConfig = ({
ErrorView = UnauthorizedView
}
} else {
const baseRoute = [adminRoute, 'globals', globalSlug].filter(Boolean).join('/')
const baseRoute = [adminRoute !== '/' && adminRoute, 'globals', globalSlug]
.filter(Boolean)
.join('/')
const currentRoute = [baseRoute, segment3, ...remainingSegments]
.filter(Boolean)

View File

@@ -1,7 +1,7 @@
import type { AdminViewComponent, AdminViewProps, EditViewComponent } from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateClientUser } from '@payloadcms/ui'
import { RenderCustomComponent, isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { RenderCustomComponent, formatAdminURL , isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js'
import React from 'react'
@@ -174,7 +174,11 @@ export const Document: React.FC<AdminViewProps> = async ({
})
if (doc?.id) {
const redirectURL = `${serverURL}${adminRoute}/collections/${collectionSlug}/${doc.id}`
const redirectURL = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${doc.id}`,
serverURL,
})
redirect(redirectURL)
} else {
notFound()

View File

@@ -11,6 +11,7 @@ import {
useStepNav,
useTranslation,
} from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { useEffect } from 'react'
export const SetDocumentStepNav: React.FC<{
@@ -35,7 +36,7 @@ export const SetDocumentStepNav: React.FC<{
const { i18n, t } = useTranslation()
const {
routes: { admin },
routes: { admin: adminRoute },
} = useConfig()
const drawerDepth = useEditDepth()
@@ -47,13 +48,23 @@ export const SetDocumentStepNav: React.FC<{
if (collectionSlug) {
nav.push({
label: getTranslation(pluralLabel, i18n),
url: isVisible ? `${admin}/collections/${collectionSlug}` : undefined,
url: isVisible
? formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}`,
})
: undefined,
})
if (isEditing) {
nav.push({
label: (useAsTitle && useAsTitle !== 'id' && title) || `${id}`,
url: isVisible ? `${admin}/collections/${collectionSlug}/${id}` : undefined,
url: isVisible
? formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${id}`,
})
: undefined,
})
} else {
nav.push({
@@ -63,7 +74,12 @@ export const SetDocumentStepNav: React.FC<{
} else if (globalSlug) {
nav.push({
label: title,
url: isVisible ? `${admin}/globals/${globalSlug}` : undefined,
url: isVisible
? formatAdminURL({
adminRoute,
path: `/globals/${globalSlug}`,
})
: undefined,
})
}
@@ -82,7 +98,7 @@ export const SetDocumentStepNav: React.FC<{
pluralLabel,
id,
useAsTitle,
admin,
adminRoute,
t,
i18n,
title,

View File

@@ -15,7 +15,7 @@ import {
useEditDepth,
useUploadEdits,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import { formatAdminURL, getFormState } from '@payloadcms/ui/shared'
import { useRouter, useSearchParams } from 'next/navigation.js'
import React, { Fragment, useCallback } from 'react'
@@ -127,7 +127,10 @@ export const DefaultEditView: React.FC = () => {
if (!isEditing && depth < 2) {
// Redirect to the same locale if it's been set
const redirectRoute = `${adminRoute}/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`
const redirectRoute = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
})
router.push(redirectRoute)
} else {
resetUploadEdits()

View File

@@ -1,6 +1,7 @@
import type { AdminViewProps } from 'payload'
import { Button, Translation } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment } from 'react'
@@ -22,9 +23,9 @@ export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult })
const {
admin: {
routes: { account: accountRoute },
routes: { account: accountRoute, login: loginRoute },
},
routes: { admin },
routes: { admin: adminRoute },
} = config
if (user) {
@@ -34,14 +35,23 @@ export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult })
<p>
<Translation
elements={{
'0': ({ children }) => <Link href={`${admin}${accountRoute}`}>{children}</Link>,
'0': ({ children }) => (
<Link
href={formatAdminURL({
adminRoute,
path: accountRoute,
})}
>
{children}
</Link>
),
}}
i18nKey="authentication:loggedInChangePassword"
t={i18n.t}
/>
</p>
<br />
<Button Link={Link} buttonStyle="secondary" el="link" to={admin}>
<Button Link={Link} buttonStyle="secondary" el="link" to={adminRoute}>
{i18n.t('general:backToDashboard')}
</Button>
</Fragment>
@@ -51,7 +61,14 @@ export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult })
return (
<Fragment>
<ForgotPasswordForm />
<Link href={`${admin}/login`}>{i18n.t('authentication:backToLogin')}</Link>
<Link
href={formatAdminURL({
adminRoute,
path: loginRoute,
})}
>
{i18n.t('authentication:backToLogin')}
</Link>
</Fragment>
)
}

View File

@@ -6,7 +6,7 @@ import {
ListQueryProvider,
TableColumnsProvider,
} from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared'
import { RenderCustomComponent, formatAdminURL } from '@payloadcms/ui/shared'
import { notFound } from 'next/navigation.js'
import { createClientCollectionConfig, mergeListSearchAndWhere } from 'payload'
import { isNumber, isReactComponentOrFunction } from 'payload/shared'
@@ -66,7 +66,7 @@ export const ListView: React.FC<AdminViewProps> = async ({
} catch (error) {} // eslint-disable-line no-empty
const {
routes: { admin },
routes: { admin: adminRoute },
} = config
if (collectionConfig) {
@@ -132,7 +132,10 @@ export const ListView: React.FC<AdminViewProps> = async ({
})}
collectionSlug={collectionSlug}
hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission}
newDocumentURL={`${admin}/collections/${collectionSlug}/create`}
newDocumentURL={formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
})}
>
<ListQueryProvider
data={data}
@@ -160,7 +163,10 @@ export const ListView: React.FC<AdminViewProps> = async ({
limit,
listPreferences,
locale: fullLocale,
newDocumentURL: `${admin}/collections/${collectionSlug}/create`,
newDocumentURL: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
}),
params,
payload,
permissions,

View File

@@ -9,6 +9,7 @@ const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.
import type { FormState, PayloadRequest } from 'payload'
import { Form, FormSubmit, PasswordField, useConfig, useTranslation } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { password } from 'payload/shared'
import type { LoginFieldProps } from '../LoginField/index.js'
@@ -29,7 +30,7 @@ export const LoginForm: React.FC<{
routes: { forgot: forgotRoute },
user: userSlug,
},
routes: { admin, api },
routes: { admin: adminRoute, api: apiRoute },
} = config
const collectionConfig = config.collections?.find((collection) => collection?.slug === userSlug)
@@ -71,12 +72,12 @@ export const LoginForm: React.FC<{
return (
<Form
action={`${api}/${userSlug}/login`}
action={`${apiRoute}/${userSlug}/login`}
className={baseClass}
disableSuccessStatus
initialState={initialState}
method="POST"
redirect={typeof searchParams?.redirect === 'string' ? searchParams.redirect : admin}
redirect={typeof searchParams?.redirect === 'string' ? searchParams.redirect : adminRoute}
waitForAutocomplete
>
<div className={`${baseClass}__inputWrap`}>
@@ -104,7 +105,14 @@ export const LoginForm: React.FC<{
}
/>
</div>
<Link href={`${admin}${forgotRoute}`}>{t('authentication:forgotPasswordQuestion')}</Link>
<Link
href={formatAdminURL({
adminRoute,
path: forgotRoute,
})}
>
{t('authentication:forgotPasswordQuestion')}
</Link>
<FormSubmit>{t('authentication:login')}</FormSubmit>
</Form>
)

View File

@@ -1,5 +1,6 @@
'use client'
import { Button, useAuth, useTranslation } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment, useEffect } from 'react'
@@ -32,9 +33,12 @@ export const LogoutClient: React.FC<{
Link={Link}
buttonStyle="secondary"
el="link"
url={`${adminRoute}/login${
redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : ''
}`}
url={formatAdminURL({
adminRoute,
path: `/login${
redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : ''
}`,
})}
>
{t('authentication:logBackIn')}
</Button>

View File

@@ -18,7 +18,7 @@ export const LogoutView: React.FC<
req: {
payload: {
config: {
routes: { admin },
routes: { admin: adminRoute },
},
},
},
@@ -27,7 +27,7 @@ export const LogoutView: React.FC<
return (
<div className={`${baseClass}__wrap`}>
<LogoutClient
adminRoute={admin}
adminRoute={adminRoute}
inactivity={inactivity}
redirect={searchParams.redirect as string}
/>

View File

@@ -18,7 +18,7 @@ export const NotFoundClient: React.FC<{
const { t } = useTranslation()
const {
routes: { admin },
routes: { admin: adminRoute },
} = useConfig()
useEffect(() => {
@@ -38,7 +38,7 @@ export const NotFoundClient: React.FC<{
<Gutter className={`${baseClass}__wrap`}>
<h1>{t('general:nothingFound')}</h1>
<p>{t('general:sorryNotFound')}</p>
<Button Link={Link} className={`${baseClass}__button`} el="link" to={`${admin}`}>
<Button Link={Link} className={`${baseClass}__button`} el="link" to={adminRoute}>
{t('general:backToDashboard')}
</Button>
</Gutter>

View File

@@ -3,6 +3,7 @@ import type { Metadata } from 'next'
import type { AdminViewComponent, SanitizedConfig } from 'payload'
import { HydrateClientUser } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import React, { Fragment } from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js'
@@ -52,7 +53,7 @@ export const NotFoundPage = async ({
const initPageResult = await initPage({
config,
redirectUnauthenticatedUser: true,
route: `${adminRoute}/not-found`,
route: formatAdminURL({ adminRoute, path: '/not-found' }),
searchParams,
})

View File

@@ -12,6 +12,7 @@ import {
useFormFields,
useTranslation,
} from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import { toast } from 'sonner'
@@ -36,8 +37,11 @@ const initialState: FormState = {
export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
const i18n = useTranslation()
const {
admin: { user: userSlug },
routes: { admin, api },
admin: {
routes: { login: loginRoute },
user: userSlug,
},
routes: { admin: adminRoute, api: apiRoute },
serverURL,
} = useConfig()
@@ -49,18 +53,23 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
async (data) => {
if (data.token) {
await fetchFullUser()
history.push(`${admin}`)
history.push(adminRoute)
} else {
history.push(`${admin}/login`)
history.push(
formatAdminURL({
adminRoute,
path: loginRoute,
}),
)
toast.success(i18n.t('general:updatedSuccessfully'))
}
},
[fetchFullUser, history, admin, i18n],
[fetchFullUser, history, adminRoute, i18n],
)
return (
<Form
action={`${serverURL}${api}/${userSlug}/reset-password`}
action={`${serverURL}${apiRoute}/${userSlug}/reset-password`}
initialState={initialState}
method="POST"
onSuccess={onSuccess}

View File

@@ -1,6 +1,7 @@
import type { AdminViewProps } from 'payload'
import { Button, Translation } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
@@ -31,7 +32,7 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
admin: {
routes: { account: accountRoute },
},
routes: { admin },
routes: { admin: adminRoute },
} = config
if (user) {
@@ -42,14 +43,23 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
<p>
<Translation
elements={{
'0': ({ children }) => <Link href={`${admin}${accountRoute}`}>{children}</Link>,
'0': ({ children }) => (
<Link
href={formatAdminURL({
adminRoute,
path: accountRoute,
})}
>
{children}
</Link>
),
}}
i18nKey="authentication:loggedInChangePassword"
t={i18n.t}
/>
</p>
<br />
<Button Link={Link} buttonStyle="secondary" el="link" to={admin}>
<Button Link={Link} buttonStyle="secondary" el="link" to={adminRoute}>
{i18n.t('general:backToDashboard')}
</Button>
</div>

View File

@@ -1,5 +1,7 @@
import type { AdminViewComponent, SanitizedConfig } from 'payload'
import { formatAdminURL } from '@payloadcms/ui/shared'
import type { initPage } from '../../utilities/initPage/index.js'
import { Account } from '../Account/index.js'
@@ -93,7 +95,7 @@ export const getViewFromConfig = ({
return isPathMatchingRoute({
currentRoute,
exact: true,
path: `${adminRoute}${route}`,
path: formatAdminURL({ adminRoute, path: route }),
})
})

View File

@@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type { SanitizedConfig } from 'payload'
import { WithServerSideProps } from '@payloadcms/ui/shared'
import { WithServerSideProps, formatAdminURL } from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js'
import React, { Fragment } from 'react'
@@ -37,13 +37,16 @@ export const RootPage = async ({
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
routes: { createFirstUser: _createFirstUserRoute },
user: userSlug,
},
routes: { admin: adminRoute },
} = config
const currentRoute = `${adminRoute}${Array.isArray(params.segments) ? `/${params.segments.join('/')}` : ''}`
const currentRoute = formatAdminURL({
adminRoute,
path: `${Array.isArray(params.segments) ? `/${params.segments.join('/')}` : ''}`,
})
const segments = Array.isArray(params.segments) ? params.segments : []
@@ -71,20 +74,20 @@ export const RootPage = async ({
})
?.then((doc) => !!doc)
const routeWithAdmin = `${adminRoute}${createFirstUserRoute}`
const createFirstUserRoute = formatAdminURL({ adminRoute, path: _createFirstUserRoute })
const collectionConfig = config.collections.find(({ slug }) => slug === userSlug)
const disableLocalStrategy = collectionConfig?.auth?.disableLocalStrategy
if (disableLocalStrategy && currentRoute === routeWithAdmin) {
if (disableLocalStrategy && currentRoute === createFirstUserRoute) {
redirect(adminRoute)
}
if (!dbHasUser && currentRoute !== routeWithAdmin && !disableLocalStrategy) {
redirect(routeWithAdmin)
if (!dbHasUser && currentRoute !== createFirstUserRoute && !disableLocalStrategy) {
redirect(createFirstUserRoute)
}
if (dbHasUser && currentRoute === routeWithAdmin) {
if (dbHasUser && currentRoute === createFirstUserRoute) {
redirect(adminRoute)
}
}

View File

@@ -1,5 +1,6 @@
import type { AdminViewProps } from 'payload'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import React from 'react'
@@ -39,10 +40,10 @@ export const Verify: React.FC<AdminViewProps> = async ({
token,
})
return redirect(`${adminRoute}/login`)
return redirect(formatAdminURL({ adminRoute, path: '/login' }))
} catch (e) {
// already verified
if (e?.status === 202) redirect(`${adminRoute}/login`)
if (e?.status === 202) redirect(formatAdminURL({ adminRoute, path: '/login' }))
textToRender = req.t('authentication:unableToVerify')
}

View File

@@ -5,7 +5,7 @@ import type React from 'react'
import { getTranslation } from '@payloadcms/translations'
import { useConfig, useLocale, useStepNav, useTranslation } from '@payloadcms/ui'
import { formatDate } from '@payloadcms/ui/shared'
import { formatAdminURL, formatDate } from '@payloadcms/ui/shared'
import { useEffect } from 'react'
export const SetStepNav: React.FC<{
@@ -62,15 +62,18 @@ export const SetStepNav: React.FC<{
nav = [
{
label: getTranslation(pluralLabel, i18n),
url: `${adminRoute}/collections/${collectionSlug}`,
url: formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` }),
},
{
label: docLabel,
url: `${adminRoute}/collections/${collectionSlug}/${id}`,
url: formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}/${id}` }),
},
{
label: 'Versions',
url: `${adminRoute}/collections/${collectionSlug}/${id}/versions`,
url: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${id}/versions`,
}),
},
{
label: doc?.createdAt
@@ -84,11 +87,17 @@ export const SetStepNav: React.FC<{
nav = [
{
label: globalConfig.label,
url: `${adminRoute}/globals/${globalConfig.slug}`,
url: formatAdminURL({
adminRoute,
path: `/globals/${globalConfig.slug}`,
}),
},
{
label: 'Versions',
url: `${adminRoute}/globals/${globalConfig.slug}/versions`,
url: formatAdminURL({
adminRoute,
path: `/globals/${globalConfig.slug}/versions`,
}),
},
{
label: doc?.createdAt

View File

@@ -1,7 +1,7 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import { Button, Modal, Pill, useConfig, useModal, useTranslation } from '@payloadcms/ui'
import { requests } from '@payloadcms/ui/shared'
import { formatAdminURL, requests } from '@payloadcms/ui/shared'
import { useRouter } from 'next/navigation.js'
import React, { Fragment, useCallback, useState } from 'react'
import { toast } from 'sonner'
@@ -24,9 +24,10 @@ const Restore: React.FC<Props> = ({
versionID,
}) => {
const {
routes: { admin, api },
routes: { admin: adminRoute, api: apiRoute },
serverURL,
} = useConfig()
const { toggleModal } = useModal()
const [processing, setProcessing] = useState(false)
const router = useRouter()
@@ -37,17 +38,23 @@ const Restore: React.FC<Props> = ({
versionDate,
})
let fetchURL = `${serverURL}${api}`
let fetchURL = `${serverURL}${apiRoute}`
let redirectURL: string
if (collectionSlug) {
fetchURL = `${fetchURL}/${collectionSlug}/versions/${versionID}`
redirectURL = `${admin}/collections/${collectionSlug}/${originalDocID}`
redirectURL = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${originalDocID}`,
})
}
if (globalSlug) {
fetchURL = `${fetchURL}/globals/${globalSlug}/versions/${versionID}`
redirectURL = `${admin}/globals/${globalSlug}`
redirectURL = formatAdminURL({
adminRoute,
path: `/globals/${globalSlug}`,
})
}
const handleRestore = useCallback(async () => {

View File

@@ -1,6 +1,6 @@
'use client'
import { useConfig, useTableCell, useTranslation } from '@payloadcms/ui'
import { formatDate } from '@payloadcms/ui/shared'
import { formatAdminURL, formatDate } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
@@ -19,7 +19,7 @@ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
}) => {
const {
admin: { dateFormat },
routes: { admin },
routes: { admin: adminRoute },
} = useConfig()
const { i18n } = useTranslation()
@@ -30,9 +30,17 @@ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
let to: string
if (collectionSlug) to = `${admin}/collections/${collectionSlug}/${docID}/versions/${versionID}`
if (collectionSlug)
to = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${docID}/versions/${versionID}`,
})
if (globalSlug) to = `${admin}/globals/${globalSlug}/versions/${versionID}`
if (globalSlug)
to = formatAdminURL({
adminRoute,
path: `/globals/${globalSlug}/versions/${versionID}`,
})
return (
<Link href={to}>

View File

@@ -699,7 +699,10 @@ export type Config = {
plugins?: Plugin[]
/** Control the routing structure that Payload binds itself to. */
routes?: {
/** @default "/admin" */
/** The route for the admin panel.
* @example "/my-admin"
* @default "/admin"
*/
admin?: string
/** @default "/api" */
api?: string

View File

@@ -3,6 +3,7 @@
import type { FormState } from 'payload'
import { useConfig, useWatchForm } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import React from 'react'
// TODO: fix this import to work in dev mode within the monorepo in a way that is backwards compatible with 1.x
// import CopyToClipboard from 'payload/dist/admin/components/elements/CopyToClipboard'
@@ -35,7 +36,10 @@ export const LinkToDocClient: React.FC = () => {
serverURL,
} = config
const href = `${serverURL}${adminRoute}/collections/${relationTo}/${docId}`
const href = `${serverURL}${formatAdminURL({
adminRoute,
path: '/collections/${relationTo}/${docId}',
})}`
return (
<div>

View File

@@ -6,6 +6,7 @@ import { Account } from '../../graphics/Account/index.js'
import { useActions } from '../../providers/Actions/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Hamburger } from '../Hamburger/index.js'
import { LocalizerLabel } from '../Localizer/LocalizerLabel/index.js'
import { Localizer } from '../Localizer/index.js'
@@ -89,7 +90,7 @@ export const AppHeader: React.FC = () => {
<LinkElement
aria-label={t('authentication:account')}
className={`${baseClass}__account`}
href={`${adminRoute}${accountRoute}`}
href={formatAdminURL({ adminRoute, path: accountRoute })}
tabIndex={0}
>
<Account />

View File

@@ -12,6 +12,7 @@ import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Button } from '../Button/index.js'
import { PopupList } from '../Popup/index.js'
import { Translation } from '../Translation/index.js'
@@ -32,7 +33,7 @@ export const DeleteDocument: React.FC<Props> = (props) => {
const { id, buttonId, collectionSlug, singularLabel, title: titleFromProps } = props
const {
routes: { admin, api },
routes: { admin: adminRoute, api },
serverURL,
} = useConfig()
@@ -75,7 +76,12 @@ export const DeleteDocument: React.FC<Props> = (props) => {
json.message,
)
return router.push(`${admin}/collections/${collectionSlug}`)
return router.push(
formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}`,
}),
)
}
toggleModal(modalSlug)
if (json.errors) {
@@ -104,7 +110,7 @@ export const DeleteDocument: React.FC<Props> = (props) => {
i18n,
title,
router,
admin,
adminRoute,
addDefaultError,
])

View File

@@ -7,6 +7,7 @@ import React, { Fragment, useEffect } from 'react'
import { useComponentMap } from '../../providers/ComponentMap/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { formatDate } from '../../utilities/formatDate.js'
import { Autosave } from '../Autosave/index.js'
import { DeleteDocument } from '../DeleteDocument/index.js'
@@ -199,7 +200,10 @@ export const DocumentControls: React.FC<{
{hasCreatePermission && (
<React.Fragment>
<PopupList.Button
href={`${adminRoute}/collections/${collectionConfig?.slug}/create`}
href={formatAdminURL({
adminRoute,
path: `/collections/${collectionConfig?.slug}/create`,
})}
id="action-create"
>
{i18n.t('general:createNew')}

View File

@@ -13,6 +13,7 @@ import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Button } from '../Button/index.js'
import { PopupList } from '../Popup/index.js'
import './index.scss'
@@ -31,13 +32,16 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
const { toggleModal } = useModal()
const locale = useLocale()
const { setModified } = useForm()
const {
routes: { api },
routes: { api: apiRoute },
serverURL,
} = useConfig()
const {
routes: { admin },
routes: { admin: adminRoute },
} = useConfig()
const [hasClicked, setHasClicked] = useState<boolean>(false)
const { i18n, t } = useTranslation()
@@ -53,7 +57,7 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
}
await requests
.post(
`${serverURL}${api}/${slug}/${id}/duplicate${locale?.code ? `?locale=${locale.code}` : ''}`,
`${serverURL}${apiRoute}/${slug}/${id}/duplicate${locale?.code ? `?locale=${locale.code}` : ''}`,
{
body: JSON.stringify({}),
headers: {
@@ -72,7 +76,10 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
)
setModified(false)
router.push(
`${admin}/collections/${slug}/${doc.id}${locale?.code ? `?locale=${locale.code}` : ''}`,
formatAdminURL({
adminRoute,
path: `/collections/${slug}/${doc.id}${locale?.code ? `?locale=${locale.code}` : ''}`,
}),
)
} else {
toast.error(
@@ -87,7 +94,7 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
locale,
modified,
serverURL,
api,
apiRoute,
slug,
id,
i18n,
@@ -97,7 +104,7 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
singularLabel,
setModified,
router,
admin,
adminRoute,
],
)

View File

@@ -5,6 +5,7 @@ import { LogOutIcon } from '../../icons/LogOut/index.js'
import { useComponentMap } from '../../providers/ComponentMap/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
const baseClass = 'nav'
@@ -19,18 +20,19 @@ const DefaultLogout: React.FC<{
admin: {
routes: { logout: logoutRoute },
},
routes: { admin },
routes: { admin: adminRoute },
} = config
const basePath = process.env.NEXT_BASE_PATH ?? ''
const LinkElement = Link || 'a'
return (
<LinkElement
aria-label={t('authentication:logOut')}
className={`${baseClass}__log-out`}
href={`${basePath}${admin}${logoutRoute}`}
href={formatAdminURL({
adminRoute,
path: logoutRoute,
})}
tabIndex={tabIndex}
>
<LogOutIcon />

View File

@@ -8,6 +8,7 @@ import { Button } from '../../elements/Button/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import './index.scss'
const baseClass = 'stay-logged-in'
@@ -24,8 +25,9 @@ export const StayLoggedInModal: React.FC = () => {
admin: {
routes: { logout: logoutRoute },
},
routes: { admin },
routes: { admin: adminRoute },
} = config
const { toggleModal } = useModal()
const { t } = useTranslation()
@@ -41,7 +43,12 @@ export const StayLoggedInModal: React.FC = () => {
buttonStyle="secondary"
onClick={() => {
toggleModal(stayLoggedInModalSlug)
router.push(`${admin}${logoutRoute}`)
router.push(
formatAdminURL({
adminRoute,
path: logoutRoute,
}),
)
}}
>
{t('authentication:logOut')}

View File

@@ -8,6 +8,7 @@ import { getTranslation } from '@payloadcms/translations'
import { useConfig } from '../../../providers/Config/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { formatAdminURL } from '../../../utilities/formatAdminURL.js'
import { useTableCell } from '../TableCellProvider/index.js'
import { CodeCell } from './fields/Code/index.js'
import { cellComponents } from './fields/index.js'
@@ -56,7 +57,10 @@ export const DefaultCell: React.FC<CellComponentProps> = (props) => {
if (isLink) {
WrapElement = Link
wrapElementProps.href = customCellContext?.collectionSlug
? `${adminRoute}/collections/${customCellContext?.collectionSlug}/${rowData.id}`
? formatAdminURL({
adminRoute,
path: `/collections/${customCellContext?.collectionSlug}/${rowData.id}`,
})
: ''
}

View File

@@ -6,6 +6,7 @@ export { PayloadIcon } from '../../graphics/Icon/index.js'
export { PayloadLogo } from '../../graphics/Logo/index.js'
export { requests } from '../../utilities/api.js'
export { findLocaleFromCode } from '../../utilities/findLocaleFromCode.js'
export { formatAdminURL } from '../../utilities/formatAdminURL.js'
export { formatDate } from '../../utilities/formatDate.js'
export { formatDocTitle } from '../../utilities/formatDocTitle.js'
export { getFormState } from '../../utilities/getFormState.js'

View File

@@ -4,6 +4,7 @@ import React from 'react'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { DefaultAccountIcon } from './Default/index.js'
import { GravatarAccountIcon } from './Gravatar/index.js'
@@ -18,9 +19,7 @@ export const Account = () => {
const { user } = useAuth()
const pathname = usePathname()
const isOnAccountPage = pathname === `${adminRoute}${accountRoute}`
const isOnAccountPage = pathname === formatAdminURL({ adminRoute, path: accountRoute })
if (!user?.email || Avatar === 'default') return <DefaultAccountIcon active={isOnAccountPage} />
if (Avatar === 'gravatar') return <GravatarAccountIcon />
if (Avatar) return <Avatar active={isOnAccountPage} />

View File

@@ -11,6 +11,7 @@ import { stayLoggedInModalSlug } from '../../elements/StayLoggedIn/index.js'
import { useDebounce } from '../../hooks/useDebounce.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { useConfig } from '../Config/index.js'
export type AuthContext<T = ClientUser> = {
@@ -48,7 +49,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
routes: { inactivity: logoutInactivityRoute },
user: userSlug,
},
routes: { admin, api },
routes: { admin: adminRoute, api: apiRoute },
serverURL,
} = config
@@ -62,14 +63,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const id = user?.id
const redirectToInactivityRoute = useCallback(() => {
if (window.location.pathname.startsWith(admin)) {
if (window.location.pathname.startsWith(adminRoute)) {
const redirectParam = `?redirect=${encodeURIComponent(window.location.pathname)}`
router.replace(`${admin}${logoutInactivityRoute}${redirectParam}`)
router.replace(
formatAdminURL({
adminRoute,
path: `${logoutInactivityRoute}${redirectParam}`,
}),
)
} else {
router.replace(`${admin}${logoutInactivityRoute}`)
router.replace(
formatAdminURL({
adminRoute,
path: logoutInactivityRoute,
}),
)
}
closeAllModals()
}, [router, admin, logoutInactivityRoute, closeAllModals])
}, [router, adminRoute, logoutInactivityRoute, closeAllModals])
const revokeTokenAndExpire = useCallback(() => {
setTokenInMemory(undefined)
@@ -97,11 +108,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (forceRefresh || (tokenExpiration && remainingTime < 120)) {
setTimeout(async () => {
try {
const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`, {
headers: {
'Accept-Language': i18n.language,
const request = await requests.post(
`${serverURL}${apiRoute}/${userSlug}/refresh-token`,
{
headers: {
'Accept-Language': i18n.language,
},
},
})
)
if (request.status === 200) {
const json = await request.json()
@@ -121,7 +135,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
[
tokenExpiration,
serverURL,
api,
apiRoute,
userSlug,
i18n,
redirectToInactivityRoute,
@@ -132,7 +146,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const refreshCookieAsync = useCallback(
async (skipSetUser?: boolean): Promise<ClientUser> => {
try {
const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`, {
const request = await requests.post(`${serverURL}${apiRoute}/${userSlug}/refresh-token`, {
headers: {
'Accept-Language': i18n.language,
},
@@ -155,18 +169,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return null
}
},
[serverURL, api, userSlug, i18n, redirectToInactivityRoute, setTokenAndExpiration],
[serverURL, apiRoute, userSlug, i18n, redirectToInactivityRoute, setTokenAndExpiration],
)
const logOut = useCallback(async () => {
setUser(null)
revokeTokenAndExpire()
try {
await requests.post(`${serverURL}${api}/${userSlug}/logout`)
await requests.post(`${serverURL}${apiRoute}/${userSlug}/logout`)
} catch (e) {
toast.error(`Logging out failed: ${e.message}`)
}
}, [serverURL, api, userSlug, revokeTokenAndExpire])
}, [serverURL, apiRoute, userSlug, revokeTokenAndExpire])
const refreshPermissions = useCallback(async () => {
const params = {
@@ -174,7 +188,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
try {
const request = await requests.get(`${serverURL}${api}/access?${qs.stringify(params)}`, {
const request = await requests.get(`${serverURL}${apiRoute}/access?${qs.stringify(params)}`, {
headers: {
'Accept-Language': i18n.language,
},
@@ -189,11 +203,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} catch (e) {
toast.error(`Refreshing permissions failed: ${e.message}`)
}
}, [serverURL, api, i18n, code])
}, [serverURL, apiRoute, i18n, code])
const fetchFullUser = React.useCallback(async () => {
try {
const request = await requests.get(`${serverURL}${api}/${userSlug}/me`, {
const request = await requests.get(`${serverURL}${apiRoute}/${userSlug}/me`, {
headers: {
'Accept-Language': i18n.language,
},
@@ -216,7 +230,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} catch (e) {
toast.error(`Fetching user failed: ${e.message}`)
}
}, [serverURL, api, userSlug, i18n.language, setTokenAndExpiration, revokeTokenAndExpire])
}, [serverURL, apiRoute, userSlug, i18n.language, setTokenAndExpiration, revokeTokenAndExpire])
// On mount, get user and set
useEffect(() => {

View File

@@ -0,0 +1,22 @@
import type { Config } from 'payload'
/** Will read the `routes.admin` config and appropriately handle `"/"` admin paths */
export const formatAdminURL = (args: {
adminRoute: Config['routes']['admin']
path: string
serverURL?: Config['serverURL']
}): string => {
const { adminRoute, path, serverURL } = args
if (adminRoute) {
if (adminRoute === '/') {
if (!path) {
return `${serverURL || ''}${adminRoute}`
}
} else {
return `${serverURL || ''}${adminRoute}${path}`
}
}
return `${serverURL || ''}${path}`
}

2
test/admin-root/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,22 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import type { Metadata } from 'next'
import config from '@payload-config'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
type Args = {
params: {
segments: string[]
}
searchParams: {
[key: string]: string | string[]
}
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) => NotFoundPage({ config, params, searchParams })
export default NotFound

View File

@@ -0,0 +1,22 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import type { Metadata } from 'next'
import config from '@payload-config'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
type Args = {
params: {
segments: string[]
}
searchParams: {
[key: string]: string | string[]
}
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams })
export default Page

View File

@@ -0,0 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { GRAPHQL_POST } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)

View File

@@ -0,0 +1,16 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import configPromise from '@payload-config'
import { RootLayout } from '@payloadcms/next/layouts'
// import '@payloadcms/ui/styles.css' // Uncomment this line if `@payloadcms/ui` in `tsconfig.json` points to `/ui/dist` instead of `/ui/src`
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import React from 'react'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const Layout = ({ children }: Args) => <RootLayout config={configPromise}>{children}</RootLayout>
export default Layout

View File

@@ -0,0 +1,5 @@
export const GET = () => {
return Response.json({
hello: 'elliot',
})
}

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'text',
},
fields: [
{
name: 'text',
type: 'text',
},
],
versions: {
drafts: true,
},
}

39
test/admin-root/config.ts Normal file
View File

@@ -0,0 +1,39 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
import { MenuGlobal } from './globals/Menu/index.js'
import { adminRoute } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection],
cors: ['http://localhost:3000', 'http://localhost:3001'],
globals: [MenuGlobal],
routes: {
admin: adminRoute,
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: postsSlug,
data: {
text: 'example post',
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,61 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import * as path from 'path'
import { adminRoute } from 'shared.js'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
test.describe('Admin Panel (Root)', () => {
let page: Page
let url: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
const { serverURL } = await initPayloadE2ENoConfig({ dirname })
url = new AdminUrlUtil(serverURL, 'posts', {
admin: adminRoute,
})
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({
customRoutes: {
admin: adminRoute,
},
page,
serverURL,
})
})
test('renders admin panel at root', async () => {
await page.goto(url.admin)
const pageURL = page.url()
expect(pageURL).toBe(url.admin)
expect(pageURL).not.toContain('/admin')
})
test('collection — navigates to list view', async () => {
await page.goto(url.list)
const pageURL = page.url()
expect(pageURL).toContain(url.list)
expect(pageURL).not.toContain('/admin')
})
test('global — navigates to edit view', async () => {
await page.goto(url.global('menu'))
const pageURL = page.url()
expect(pageURL).toBe(url.global('menu'))
expect(pageURL).not.toContain('/admin')
})
})

View File

@@ -0,0 +1,21 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.FlatConfig} */
let FlatConfig
/** @type {FlatConfig[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
project: './tsconfig.eslint.json',
tsconfigDirName: import.meta.dirname,
...rootParserOptions,
},
},
},
]
export default index

View File

@@ -0,0 +1,13 @@
import type { GlobalConfig } from 'payload'
export const menuSlug = 'menu'
export const MenuGlobal: GlobalConfig = {
slug: menuSlug,
fields: [
{
name: 'globalText',
type: 'text',
},
],
}

View File

@@ -0,0 +1,72 @@
import type { Payload } from 'payload'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { postsSlug } from './collections/Posts/index.js'
import configPromise from './config.js'
let payload: Payload
let token: string
let restClient: NextRESTClient
const { email, password } = devUser
describe('Admin (Root) Tests', () => {
// --__--__--__--__--__--__--__--__--__
// Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__
beforeAll(async () => {
const initialized = await initPayloadInt(configPromise)
;({ payload, restClient } = initialized)
const data = await restClient
.POST('/users/login', {
body: JSON.stringify({
email,
password,
}),
})
.then((res) => res.json())
token = data.token
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
// --__--__--__--__--__--__--__--__--__
// You can run tests against the local API or the REST API
// use the tests below as a guide
// --__--__--__--__--__--__--__--__--__
it('local API example', async () => {
const newPost = await payload.create({
collection: postsSlug,
data: {
text: 'LOCAL API EXAMPLE',
},
})
expect(newPost.text).toEqual('LOCAL API EXAMPLE')
})
it('rest API example', async () => {
const data = await restClient
.POST(`/${postsSlug}`, {
body: JSON.stringify({
text: 'REST API EXAMPLE',
}),
headers: {
Authorization: `JWT ${token}`,
},
})
.then((res) => res.json())
expect(data.doc.text).toEqual('REST API EXAMPLE')
})
})

5
test/admin-root/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -0,0 +1,30 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import withPayload from '../../packages/next/src/withPayload.js'
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default withBundleAnalyzer(
withPayload({
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
domains: ['localhost'],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
}),
)

View File

@@ -0,0 +1,130 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
posts: Post;
users: User;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
db: {
defaultIDType: string;
};
globals: {
menu: Menu;
};
locale: null;
user: User & {
collection: 'users';
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "menu".
*/
export interface Menu {
id: string;
globalText?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export const adminRoute = '/'

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@payload-config": ["./config.ts"],
"@payloadcms/ui/assets": ["../../packages/ui/src/assets/index.ts"],
"@payloadcms/ui/elements/*": ["../../packages/ui/src/elements/*/index.tsx"],
"@payloadcms/ui/fields/*": ["../../packages/ui/src/fields/*/index.tsx"],
"@payloadcms/ui/forms/*": ["../../packages/ui/src/forms/*/index.tsx"],
"@payloadcms/ui/graphics/*": ["../../packages/ui/src/graphics/*/index.tsx"],
"@payloadcms/ui/hooks/*": ["../../packages/ui/src/hooks/*.ts"],
"@payloadcms/ui/icons/*": ["../../packages/ui/src/icons/*/index.tsx"],
"@payloadcms/ui/providers/*": ["../../packages/ui/src/providers/*/index.tsx"],
"@payloadcms/ui/templates/*": ["../../packages/ui/src/templates/*/index.tsx"],
"@payloadcms/ui/utilities/*": ["../../packages/ui/src/utilities/*.ts"],
"@payloadcms/ui/scss": ["../../packages/ui/src/scss.scss"],
"@payloadcms/ui/scss/app.scss": ["../../packages/ui/src/scss/app.scss"],
"payload/types": ["../../packages/payload/src/exports/types.ts"],
"@payloadcms/next/*": ["../../packages/next/src/*"],
"@payloadcms/next": ["../../packages/next/src/exports/*"]
}
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -1,3 +1,5 @@
import type { Config } from 'payload'
export const slugSingularLabel = 'Post'
export const slugPluralLabel = 'Posts'
@@ -35,7 +37,7 @@ export const customCollectionParamViewPathBase = '/custom-param'
export const customCollectionParamViewPath = `${customCollectionParamViewPathBase}/:slug`
export const customAdminRoutes = {
export const customAdminRoutes: Config['admin']['routes'] = {
inactivity: '/custom-inactivity',
logout: '/custom-logout',
}

View File

@@ -160,9 +160,7 @@ export async function buildConfigWithDefaults(
secret: 'TEST_SECRET',
sharp,
telemetry: false,
...testConfig,
i18n: {
supportedLanguages: {
de,
@@ -182,6 +180,7 @@ export async function buildConfigWithDefaults(
if (!config.admin) {
config.admin = {}
}
if (config.admin.autoLogin === undefined) {
config.admin.autoLogin =
process.env.PAYLOAD_PUBLIC_DISABLE_AUTO_LOGIN === 'true'

View File

@@ -33,15 +33,15 @@ process.env.PAYLOAD_DROP_DATABASE = 'true'
const { beforeTest } = await createTestHooks(testSuiteArg)
await beforeTest()
const rootDir = getNextJSRootDir(testSuiteArg)
const { rootDir, adminRoute } = getNextJSRootDir(testSuiteArg)
// Open the admin if the -o flag is passed
if (args.o) {
await open('http://localhost:3000/admin')
await open(`http://localhost:3000${adminRoute}`)
}
// @ts-expect-error
await nextDev({ port: process.env.PORT || 3000, dirname: rootDir }, 'default', rootDir)
// fetch the admin url to force a render
fetch(`http://localhost:${process.env.PORT || 3000}/admin`)
fetch(`http://localhost:${process.env.PORT || 3000}${adminRoute}`)

View File

@@ -1,3 +1,9 @@
// IMPORTANT: ensure that imports do not contain React components, etc. as this breaks Playwright tests
// Instead of pointing to the bundled code, which will include React components, use direct import paths
import { formatAdminURL } from '../../packages/ui/src/utilities/formatAdminURL.js' // eslint-disable-line payload/no-relative-monorepo-imports
import type { Config } from 'payload'
export class AdminUrlUtil {
account: string
@@ -5,17 +11,53 @@ export class AdminUrlUtil {
create: string
entitySlug: string
list: string
constructor(serverURL: string, slug: string) {
this.account = `${serverURL}/admin/account`
this.admin = `${serverURL}/admin`
this.list = `${this.admin}/collections/${slug}`
this.create = `${this.list}/create`
routes: Config['routes']
serverURL: string
constructor(serverURL: string, slug: string, routes?: Config['routes']) {
this.routes = {
admin: routes?.admin || '/admin',
}
this.serverURL = serverURL
this.entitySlug = slug
this.admin = formatAdminURL({
adminRoute: this.routes.admin,
path: '',
serverURL: this.serverURL,
})
this.account = formatAdminURL({
adminRoute: this.routes.admin,
path: '/account',
serverURL: this.serverURL,
})
this.list = formatAdminURL({
adminRoute: this.routes.admin,
path: `/collections/${this.entitySlug}`,
serverURL: this.serverURL,
})
this.create = formatAdminURL({
adminRoute: this.routes.admin,
path: `/collections/${this.entitySlug}/create`,
serverURL: this.serverURL,
})
}
collection(slug: string): string {
return `${this.admin}/collections/${slug}`
return formatAdminURL({
adminRoute: this.routes.admin,
path: `/collections/${slug}`,
serverURL: this.serverURL,
})
}
edit(id: number | string): string {
@@ -23,6 +65,10 @@ export class AdminUrlUtil {
}
global(slug: string): string {
return `${this.admin}/globals/${slug}`
return formatAdminURL({
adminRoute: this.routes.admin,
path: `/globals/${slug}`,
serverURL: this.serverURL,
})
}
}

View File

@@ -2,6 +2,8 @@ import fs from 'fs'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { adminRoute as rootAdminRoute } from '../admin-root/shared.js'
const _filename = fileURLToPath(import.meta.url)
const _dirname = dirname(_filename)
@@ -17,9 +19,23 @@ export const getNextJSRootDir = (testSuite) => {
// Swallow err - no config found
}
if (hasNextConfig) return testSuiteDir
let adminRoute = '/admin'
if (testSuite === 'admin-root') {
adminRoute = rootAdminRoute
}
if (hasNextConfig) {
return {
rootDir: testSuiteDir,
adminRoute,
}
}
// If no next config found in test suite,
// return monorepo root dir
return resolve(_dirname, '../../')
return {
rootDir: resolve(_dirname, '../../'),
adminRoute,
}
}

View File

@@ -39,7 +39,7 @@ export async function initPayloadE2ENoConfig<T extends GeneratedTypes<T>>({
await startMemoryDB()
const dir = getNextJSRootDir(testSuiteName)
const { rootDir } = getNextJSRootDir(testSuiteName)
if (prebuild) {
await new Promise<void>((res, rej) => {
@@ -50,7 +50,7 @@ export async function initPayloadE2ENoConfig<T extends GeneratedTypes<T>>({
env: {
PATH: process.env.PATH,
NODE_ENV: 'production',
NEXTJS_DIR: dir,
NEXTJS_DIR: rootDir,
},
})
@@ -68,7 +68,7 @@ export async function initPayloadE2ENoConfig<T extends GeneratedTypes<T>>({
dev: !prebuild,
hostname: 'localhost',
port,
dir,
dir: rootDir,
})
const handle = app.getRequestHandler()

View File

@@ -1,6 +1,5 @@
#custom-css {
font-family: monospace;
background-image: url('/placeholder.png');
}
#custom-css::after {

View File