feat: route transitions (#9275)

Due to nature of server-side rendering, navigation within the admin
panel can lead to slow page response times. This can lead to the feeling
of an unresponsive app after clicking a link, for example, where the
page remains in a stale state while the server is processing. This is
especially noticeable on slow networks when navigating to data heavy or
process intensive pages.

To alleviate the bad UX that this causes, the user needs immediate
visual indication that _something_ is taking place. This PR renders a
progress bar in the admin panel which is immediately displayed when a
user clicks a link, and incrementally grows in size until the new route
has loaded in.

Inspired by https://github.com/vercel/react-transition-progress.

Old:

https://github.com/user-attachments/assets/1820dad1-3aea-417f-a61d-52244b12dc8d

New:

https://github.com/user-attachments/assets/99f4bb82-61d9-4a4c-9bdf-9e379bbafd31

To tie into the progress bar, you'll need to use Payload's new `Link`
component instead of the one provided by Next.js:

```diff
- import { Link } from 'next/link'
+ import { Link } from '@payloadcms/ui'
```

Here's an example:

```tsx
import { Link } from '@payloadcms/ui'

const MyComponent = () => {
  return (
    <Link href="/somewhere">
      Go Somewhere
    </Link>
  )
}
```

In order to trigger route transitions for a direct router event such as
`router.push`, you'll need to wrap your function calls with the
`startRouteTransition` method provided by the `useRouteTransition` hook.

```ts
'use client'
import React, { useCallback } from 'react'
import { useTransition } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'

const MyComponent: React.FC = () => {
  const router = useRouter()
  const { startRouteTransition } = useRouteTransition()
 
  const redirectSomewhere = useCallback(() => {
    startRouteTransition(() => router.push('/somewhere'))
  }, [startRouteTransition, router])
 
  // ...
}
```

In the future [Next.js might provide native support for
this](https://github.com/vercel/next.js/discussions/41934#discussioncomment-12077414),
and if it does, this implementation can likely be simplified.

Of course there are other ways of achieving this, such as with
[Suspense](https://react.dev/reference/react/Suspense), but they all
come with a different set of caveats. For example with Suspense, you
must provide a fallback component. This means that the user might be
able to immediately navigate to the new page, which is good, but they'd
be presented with a skeleton UI while the other parts of the page stream
in. Not necessarily an improvement to UX as there would be multiple
loading states with this approach.

There are other problems with using Suspense as well. Our default
template, for example, contains the app header and sidebar which are not
rendered within the root layout. This means that they need to stream in
every single time. On fast networks, this would also lead to a
noticeable "blink" unless there is some mechanism by which we can detect
and defer the fallback from ever rendering in such cases. Might still be
worth exploring in the future though.
This commit is contained in:
Jacob Fletcher
2025-02-13 09:48:13 -05:00
committed by GitHub
parent 706410e693
commit 3f550bc0ec
53 changed files with 773 additions and 290 deletions

View File

@@ -1113,5 +1113,40 @@ setParams({ depth: 2 })
This is useful for scenarios where you need to trigger another fetch regardless of the `url` argument changing.
## useRouteTransition
Route transitions are useful in showing immediate visual feedback to the user when navigating between pages. This is especially useful on slow networks when navigating to data heavy or process intensive pages.
By default, any instances of `Link` from `@payloadcms/ui` will trigger route transitions dy default.
```tsx
import { Link } from '@payloadcms/ui'
const MyComponent = () => {
return (
<Link href="/somewhere">
Go Somewhere
</Link>
)
}
```
You can also trigger route transitions programmatically, such as when using `router.push` from `next/router`. To do this, wrap your function calls with the `startRouteTransition` method provided by the `useRouteTransition` hook.
```ts
'use client'
import React, { useCallback } from 'react'
import { useTransition } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
const MyComponent: React.FC = () => {
const router = useRouter()
const { startRouteTransition } = useRouteTransition()
const redirectSomewhere = useCallback(() => {
startRouteTransition(() => router.push('/somewhere'))
}, [startRouteTransition, router])
// ...
}
```

View File

@@ -1,13 +1,11 @@
'use client'
import type { SanitizedConfig } from 'payload'
import { Link } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import { useParams, usePathname, useSearchParams } from 'next/navigation.js'
import React from 'react'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const DocumentTabLink: React.FC<{
adminRoute: SanitizedConfig['routes']['admin']
ariaLabel?: string

View File

@@ -4,9 +4,8 @@ import type { groupNavItems } from '@payloadcms/ui/shared'
import type { NavPreferences } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
import { Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
import { EntityType, formatAdminURL } from '@payloadcms/ui/shared'
import LinkWithDefault from 'next/link.js'
import { usePathname } from 'next/navigation.js'
import React, { Fragment } from 'react'
@@ -45,9 +44,6 @@ export const DefaultNavClient: React.FC<{
id = `nav-global-${slug}`
}
const Link = (LinkWithDefault.default ||
LinkWithDefault) as typeof LinkWithDefault.default
const LinkElement = Link || 'a'
const activeCollection =
pathname.startsWith(href) && ['/', undefined].includes(pathname[href.length])

View File

@@ -1,14 +1,14 @@
import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { ServerProps } from 'payload'
import { Logout } from '@payloadcms/ui'
import { Link, Logout } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
import React from 'react'
import './index.scss'
import { NavHamburger } from './NavHamburger/index.js'
import { NavWrapper } from './NavWrapper/index.js'
import './index.scss'
const baseClass = 'nav'
@@ -73,6 +73,7 @@ export const DefaultNav: React.FC<NavProps> = async (props) => {
const LogoutComponent = RenderServerComponent({
clientProps: {
documentSubViewType,
Link,
viewType,
},
Component: logout?.Button,

View File

@@ -2,7 +2,7 @@ import type { AcceptedLanguages } from '@payloadcms/translations'
import type { ImportMap, LanguageOptions, SanitizedConfig, ServerFunctionClient } from 'payload'
import { rtlLanguages } from '@payloadcms/translations'
import { RootProvider } from '@payloadcms/ui'
import { ProgressBar, RootProvider } from '@payloadcms/ui'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
import { getPayload, getRequestLanguage, parseCookies } from 'payload'
@@ -135,6 +135,7 @@ export const RootLayout = async ({
translations={req.i18n.translations}
user={req.user}
>
<ProgressBar />
{Array.isArray(config.admin?.components?.providers) &&
config.admin?.components?.providers.length > 0 ? (
<NestProviders

View File

@@ -1,18 +1,15 @@
import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { AdminViewProps } from 'payload'
import { HydrateAuthProvider, SetStepNav } from '@payloadcms/ui'
import { HydrateAuthProvider, Link, SetStepNav } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment } from 'react'
import { DefaultDashboard } from './Default/index.js'
export { generateDashboardMetadata } from './meta.js'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const Dashboard: React.FC<AdminViewProps> = async ({
initPageResult,
params,
@@ -119,7 +116,6 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
serverProps: {
globalData,
i18n,
Link,
locale,
navGroups,
params,

View File

@@ -1,8 +1,7 @@
import type { AdminViewProps } from 'payload'
import { Button } from '@payloadcms/ui'
import { Button, Link } from '@payloadcms/ui'
import { formatAdminURL, Translation } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment } from 'react'
import { FormHeader } from '../../elements/FormHeader/index.js'
@@ -10,7 +9,6 @@ import { ForgotPasswordForm } from './ForgotPasswordForm/index.js'
export { generateForgotPasswordMetadata } from './meta.js'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const forgotPasswordBaseClass = 'forgot-password'
export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult }) => {

View File

@@ -28,6 +28,7 @@ import {
useDocumentEvents,
useDocumentInfo,
useEditDepth,
useRouteTransition,
useServerFunctions,
useTranslation,
useUploadEdits,
@@ -131,6 +132,7 @@ const PreviewView: React.FC<Props> = ({
const { reportUpdate } = useDocumentEvents()
const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions()
const { startRouteTransition } = useRouteTransition()
const docConfig = collectionConfig || globalConfig
@@ -211,7 +213,8 @@ const PreviewView: React.FC<Props> = ({
adminRoute,
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
})
router.push(redirectRoute)
startRouteTransition(() => router.push(redirectRoute))
} else {
resetUploadEdits()
}
@@ -269,6 +272,7 @@ const PreviewView: React.FC<Props> = ({
router,
setDocumentIsLocked,
updateSavedDocumentData,
startRouteTransition,
user,
userSlug,
autosaveEnabled,

View File

@@ -1,15 +1,21 @@
'use client'
import LinkImport from 'next/link.js'
import React from 'react'
const baseClass = 'login__form'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
import type { UserWithToken } from '@payloadcms/ui'
import type { FormState } from 'payload'
import { Form, FormSubmit, PasswordField, useAuth, useConfig, useTranslation } from '@payloadcms/ui'
import {
Form,
FormSubmit,
Link,
PasswordField,
useAuth,
useConfig,
useTranslation,
} from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { getLoginOptions } from 'payload/shared'

View File

@@ -5,8 +5,8 @@ import { redirect } from 'next/navigation.js'
import React, { Fragment } from 'react'
import { Logo } from '../../elements/Logo/index.js'
import './index.scss'
import { LoginForm } from './LoginForm/index.js'
import './index.scss'
export { generateLoginMetadata } from './meta.js'

View File

@@ -1,7 +1,14 @@
'use client'
import { Button, LoadingOverlay, toast, useAuth, useTranslation } from '@payloadcms/ui'
import {
Button,
Link,
LoadingOverlay,
toast,
useAuth,
useRouteTransition,
useTranslation,
} from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import { useRouter } from 'next/navigation.js'
import React, { useEffect } from 'react'
@@ -9,8 +16,6 @@ import './index.scss'
const baseClass = 'logout'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const LogoutClient: React.FC<{
adminRoute: string
inactivity?: boolean
@@ -20,6 +25,8 @@ export const LogoutClient: React.FC<{
const { logOut, user } = useAuth()
const { startRouteTransition } = useRouteTransition()
const [isLoggedOut, setIsLoggedOut] = React.useState<boolean>(!user)
const logOutSuccessRef = React.useRef(false)
@@ -41,21 +48,22 @@ export const LogoutClient: React.FC<{
const handleLogOut = React.useCallback(async () => {
const loggedOut = await logOut()
setIsLoggedOut(loggedOut)
if (!inactivity && loggedOut && !logOutSuccessRef.current) {
toast.success(t('authentication:loggedOutSuccessfully'))
logOutSuccessRef.current = true
router.push(loginRoute)
startRouteTransition(() => router.push(loginRoute))
return
}
}, [inactivity, logOut, loginRoute, router, t])
}, [inactivity, logOut, loginRoute, router, startRouteTransition, t])
useEffect(() => {
if (!isLoggedOut) {
void handleLogOut()
} else {
router.push(loginRoute)
startRouteTransition(() => router.push(loginRoute))
}
}, [handleLogOut, isLoggedOut, loginRoute, router])
}, [handleLogOut, isLoggedOut, loginRoute, router, startRouteTransition])
if (isLoggedOut && inactivity) {
return (

View File

@@ -1,14 +1,11 @@
'use client'
import { Button, Gutter, useConfig, useStepNav, useTranslation } from '@payloadcms/ui'
import LinkImport from 'next/link.js'
import { Button, Gutter, Link, useConfig, useStepNav, useTranslation } from '@payloadcms/ui'
import React, { useEffect } from 'react'
import './index.scss'
const baseClass = 'not-found'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const NotFoundClient: React.FC<{
marginTop?: 'large'
}> = (props) => {

View File

@@ -1,8 +1,7 @@
import type { AdminViewProps } from 'payload'
import { Button } from '@payloadcms/ui'
import { Button, Link } from '@payloadcms/ui'
import { formatAdminURL, Translation } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
import { FormHeader } from '../../elements/FormHeader/index.js'
@@ -11,8 +10,6 @@ import { ResetPasswordForm } from './ResetPasswordForm/index.js'
export const resetPasswordBaseClass = 'reset-password'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export { generateResetPasswordMetadata } from './meta.js'
export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params }) => {

View File

@@ -1,15 +1,12 @@
import type { AdminViewComponent, PayloadServerReactComponent } from 'payload'
import { Button } from '@payloadcms/ui'
import { Button, Link } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
import { FormHeader } from '../../elements/FormHeader/index.js'
import './index.scss'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export { generateUnauthorizedMetadata } from './meta.js'
const baseClass = 'unauthorized'

View File

@@ -1,5 +1,5 @@
'use client'
import { toast } from '@payloadcms/ui'
import { toast, useRouteTransition } from '@payloadcms/ui'
import { useRouter } from 'next/navigation.js'
import React, { useEffect } from 'react'
@@ -9,15 +9,17 @@ type Props = {
}
export function ToastAndRedirect({ message, redirectTo }: Props) {
const router = useRouter()
const { startRouteTransition } = useRouteTransition()
const hasToastedRef = React.useRef(false)
useEffect(() => {
let timeoutID
if (toast) {
timeoutID = setTimeout(() => {
toast.success(message)
hasToastedRef.current = true
router.push(redirectTo)
startRouteTransition(() => router.push(redirectTo))
}, 100)
}
@@ -26,7 +28,7 @@ export function ToastAndRedirect({ message, redirectTo }: Props) {
clearTimeout(timeoutID)
}
}
}, [router, redirectTo, message])
}, [router, redirectTo, message, startRouteTransition])
return null
}

View File

@@ -1,7 +1,14 @@
'use client'
import type { OptionObject } from 'payload'
import { CheckboxInput, Gutter, useConfig, useDocumentInfo, useTranslation } from '@payloadcms/ui'
import {
CheckboxInput,
Gutter,
useConfig,
useDocumentInfo,
useRouteTransition,
useTranslation,
} from '@payloadcms/ui'
import { formatDate } from '@payloadcms/ui/shared'
import { usePathname, useRouter, useSearchParams } from 'next/navigation.js'
import React, { useEffect, useMemo, useState } from 'react'
@@ -42,6 +49,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
const { i18n } = useTranslation()
const { id, collectionSlug, globalSlug } = useDocumentInfo()
const { startRouteTransition } = useRouteTransition()
const [collectionConfig] = useState(() => getEntityConfig({ collectionSlug }))
@@ -59,8 +67,8 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
}
useEffect(() => {
// If the selected comparison doc or locales change, update URL params so that version page RSC
// can update the version comparison state
// If the selected comparison doc or locales change, update URL params so that version page
// This is so that RSC can update the version comparison state
const current = new URLSearchParams(Array.from(searchParams.entries()))
if (!compareValue) {
@@ -68,6 +76,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
} else {
current.set('compareValue', compareValue?.value)
}
if (!selectedLocales) {
current.delete('localeCodes')
} else {
@@ -82,8 +91,18 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
const search = current.toString()
const query = search ? `?${search}` : ''
router.push(`${pathname}${query}`)
}, [compareValue, pathname, router, searchParams, selectedLocales, modifiedOnly])
// TODO: this transition occurs multiple times during the initial rendering phases, need to evaluate
startRouteTransition(() => router.push(`${pathname}${query}`))
}, [
compareValue,
pathname,
router,
searchParams,
selectedLocales,
modifiedOnly,
startRouteTransition,
])
const {
admin: { dateFormat },

View File

@@ -9,6 +9,7 @@ import {
PopupList,
useConfig,
useModal,
useRouteTransition,
useTranslation,
} from '@payloadcms/ui'
import { formatAdminURL, requests } from '@payloadcms/ui/shared'
@@ -48,6 +49,7 @@ const Restore: React.FC<Props> = ({
const router = useRouter()
const { i18n, t } = useTranslation()
const [draft, setDraft] = useState(false)
const { startRouteTransition } = useRouteTransition()
const restoreMessage = t('version:aboutToRestoreGlobal', {
label: getTranslation(label, i18n),
@@ -87,11 +89,12 @@ const Restore: React.FC<Props> = ({
if (res.status === 200) {
const json = await res.json()
toast.success(json.message)
router.push(redirectURL)
startRouteTransition(() => router.push(redirectURL))
} else {
toast.error(t('version:problemRestoringVersion'))
}
}, [fetchURL, redirectURL, t, i18n, router])
}, [fetchURL, redirectURL, t, i18n, router, startRouteTransition])
return (
<Fragment>
<div className={[baseClass, className].filter(Boolean).join(' ')}>

View File

@@ -1,11 +1,8 @@
'use client'
import { useConfig, useTranslation } from '@payloadcms/ui'
import { Link, useConfig, useTranslation } from '@payloadcms/ui'
import { formatAdminURL, formatDate } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
type CreatedAtCellProps = {
collectionSlug?: string
docID?: number | string

View File

@@ -1,12 +1,9 @@
'use client'
import { CopyToClipboard, useConfig, useField } from '@payloadcms/ui'
import { CopyToClipboard, Link, useConfig, useField } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const LinkToDocClient: React.FC = () => {
const { config } = useConfig()

View File

@@ -2,15 +2,13 @@ import type { SerializedLexicalNode } from 'lexical'
import type { Payload } from 'payload'
import { getTranslation, type I18nClient } from '@payloadcms/translations'
import { Link } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
import type { SanitizedServerEditorConfig } from '../lexical/config/types.js'
import type { LexicalFieldAdminProps, LexicalRichTextCellProps } from '../types.js'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
function recurseEditorState(
editorState: SerializedLexicalNode[],
textContent: React.ReactNode[],

View File

@@ -1,12 +1,10 @@
import type { DefaultServerCellComponentProps, Payload } from 'payload'
import { getTranslation, type I18nClient } from '@payloadcms/translations'
import { Link } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const RscEntrySlateCell: React.FC<
{
i18n: I18nClient

View File

@@ -1,5 +1,4 @@
'use client'
import LinkWithDefault from 'next/link.js'
import React, { useEffect, useRef, useState } from 'react'
import { Account } from '../../graphics/Account/index.js'
@@ -8,13 +7,14 @@ 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 { Link } from '../Link/index.js'
import { Localizer } from '../Localizer/index.js'
import { LocalizerLabel } from '../Localizer/LocalizerLabel/index.js'
import { useNav } from '../Nav/context.js'
import { NavToggler } from '../Nav/NavToggler/index.js'
import { RenderCustomComponent } from '../RenderCustomComponent/index.js'
import { StepNav } from '../StepNav/index.js'
import './index.scss'
import { StepNav } from '../StepNav/index.js'
const baseClass = 'app-header'
@@ -59,8 +59,6 @@ export function AppHeader({ CustomAvatar, CustomIcon }: Props) {
}
}, [Actions])
const Link = LinkWithDefault.default
const LinkElement = Link || 'a'
const ActionComponents = Actions ? Object.values(Actions) : []

View File

@@ -1,12 +1,10 @@
'use client'
import type { MouseEvent } from 'react'
import LinkImport from 'next/link.js' // TODO: abstract this out to support all routers
import React from 'react'
import './index.scss'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
import { Link } from '../Link/index.js'
const baseClass = 'banner'

View File

@@ -13,6 +13,7 @@ import { useDocumentEvents } from '../../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
import { useEditDepth } from '../../../providers/EditDepth/index.js'
import { OperationProvider } from '../../../providers/Operation/index.js'
import { useRouteTransition } from '../../../providers/RouteTransition/index.js'
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
import { useUploadEdits } from '../../../providers/UploadEdits/index.js'
import { abortAndIgnore, handleAbortRef } from '../../../utilities/abortAndIgnore.js'
@@ -62,6 +63,7 @@ export function EditForm({ submitted }: EditFormProps) {
const params = useSearchParams()
const { reportUpdate } = useDocumentEvents()
const { resetUploadEdits } = useUploadEdits()
const { startRouteTransition } = useRouteTransition()
const locale = params.get('locale')
@@ -89,7 +91,8 @@ export function EditForm({ submitted }: EditFormProps) {
adminRoute,
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
})
router.push(redirectRoute)
startRouteTransition(() => router.push(redirectRoute))
} else {
resetUploadEdits()
}
@@ -104,6 +107,7 @@ export function EditForm({ submitted }: EditFormProps) {
reportUpdate,
resetUploadEdits,
router,
startRouteTransition,
],
)

View File

@@ -12,13 +12,14 @@ import { useFormModified } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { DrawerHeader } from '../BulkUpload/Header/index.js'
import { Button } from '../Button/index.js'
import { Drawer } from '../Drawer/index.js'
import './index.scss'
import { PopupList } from '../Popup/index.js'
import './index.scss'
const baseClass = 'copy-locale-data'
@@ -38,6 +39,7 @@ export const CopyLocaleData: React.FC = () => {
const { toggleModal } = useModal()
const { copyDataFromLocale } = useServerFunctions()
const router = useRouter()
const { startRouteTransition } = useRouteTransition()
const localeOptions =
(localization &&
@@ -77,9 +79,13 @@ export const CopyLocaleData: React.FC = () => {
})
setCopying(false)
startRouteTransition(() =>
router.push(
`${serverURL}${admin}/${collectionSlug ? `collections/${collectionSlug}/${id}` : `globals/${globalSlug}`}?locale=${to}`,
),
)
toggleModal(drawerSlug)
} catch (error) {
toast.error(error.message)
@@ -95,6 +101,7 @@ export const CopyLocaleData: React.FC = () => {
router,
serverURL,
admin,
startRouteTransition,
],
)

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientCollectionConfig, SanitizedCollectionConfig } from 'payload'
import type { SanitizedCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
@@ -13,6 +13,7 @@ import { useForm } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
@@ -63,6 +64,7 @@ export const DeleteDocument: React.FC<Props> = (props) => {
const { i18n, t } = useTranslation()
const { title } = useDocumentInfo()
const editDepth = useEditDepth()
const { startRouteTransition } = useRouteTransition()
const titleToRender = titleFromProps || title || id
@@ -105,11 +107,13 @@ export const DeleteDocument: React.FC<Props> = (props) => {
)
if (redirectAfterDelete) {
return router.push(
return startRouteTransition(() =>
router.push(
formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}`,
}),
),
)
}
@@ -155,6 +159,7 @@ export const DeleteDocument: React.FC<Props> = (props) => {
redirectAfterDelete,
onDelete,
collectionConfig,
startRouteTransition,
])
if (id) {

View File

@@ -16,8 +16,8 @@ import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js'
import { Button } from '../Button/index.js'
import './index.scss'
import { Pill } from '../Pill/index.js'
import './index.scss'
const baseClass = 'delete-documents'

View File

@@ -3,6 +3,7 @@ import type { ClientUser } from 'payload'
import React, { useEffect } from 'react'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { isClientUserObject } from '../../utilities/isClientUserObject.js'
import { Button } from '../Button/index.js'
@@ -37,6 +38,7 @@ export const DocumentLocked: React.FC<{
}> = ({ handleGoBack, isActive, onReadOnly, onTakeOver, updatedAt, user }) => {
const { closeModal, openModal } = useModal()
const { t } = useTranslation()
const { startRouteTransition } = useRouteTransition()
useEffect(() => {
if (isActive) {
@@ -47,7 +49,13 @@ export const DocumentLocked: React.FC<{
}, [isActive, openModal, closeModal])
return (
<Modal className={baseClass} onClose={handleGoBack} slug={modalSlug}>
<Modal
className={baseClass}
onClose={() => {
startRouteTransition(() => handleGoBack())
}}
slug={modalSlug}
>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:documentLocked')}</h1>
@@ -65,7 +73,9 @@ export const DocumentLocked: React.FC<{
<Button
buttonStyle="secondary"
id={`${modalSlug}-go-back`}
onClick={handleGoBack}
onClick={() => {
startRouteTransition(() => handleGoBack())
}}
size="large"
>
{t('general:goBack')}

View File

@@ -1,6 +1,7 @@
'use client'
import React, { useEffect } from 'react'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { Modal, useModal } from '../Modal/index.js'
@@ -17,6 +18,7 @@ export const DocumentTakeOver: React.FC<{
}> = ({ handleBackToDashboard, isActive, onReadOnly }) => {
const { closeModal, openModal } = useModal()
const { t } = useTranslation()
const { startRouteTransition } = useRouteTransition()
useEffect(() => {
if (isActive) {
@@ -37,7 +39,9 @@ export const DocumentTakeOver: React.FC<{
<Button
buttonStyle="primary"
id={`${modalSlug}-back-to-dashboard`}
onClick={handleBackToDashboard}
onClick={() => {
startRouteTransition(() => handleBackToDashboard())
}}
size="large"
>
{t('general:backToDashboard')}

View File

@@ -1,6 +1,6 @@
'use client'
import type { ClientCollectionConfig, SanitizedCollectionConfig } from 'payload'
import type { SanitizedCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
@@ -14,13 +14,14 @@ import { useForm, useFormModified } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/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 { drawerZBase } from '../Drawer/index.js'
import { PopupList } from '../Popup/index.js'
import './index.scss'
import { PopupList } from '../Popup/index.js'
const baseClass = 'duplicate'
@@ -44,6 +45,7 @@ export const DuplicateDocument: React.FC<Props> = ({
const { toggleModal } = useModal()
const locale = useLocale()
const { setModified } = useForm()
const { startRouteTransition } = useRouteTransition()
const {
config: {
@@ -93,11 +95,13 @@ export const DuplicateDocument: React.FC<Props> = ({
setModified(false)
if (redirectAfterDuplicate) {
return startRouteTransition(() =>
router.push(
formatAdminURL({
adminRoute,
path: `/collections/${slug}/${doc.id}${locale?.code ? `?locale=${locale.code}` : ''}`,
}),
),
)
}
@@ -131,6 +135,7 @@ export const DuplicateDocument: React.FC<Props> = ({
router,
adminRoute,
collectionConfig,
startRouteTransition,
],
)

View File

@@ -18,7 +18,7 @@ const Component: React.FC<{
onCancel: () => void
onConfirm: () => void
}> = ({ isActive, onCancel, onConfirm }) => {
const { closeModal, modalState, openModal } = useModal()
const { closeModal, openModal } = useModal()
const { t } = useTranslation()
// Manually check for modal state as 'esc' key will not trigger the nav inactivity

View File

@@ -6,9 +6,11 @@
import { useRouter } from 'next/navigation.js'
import { useCallback, useEffect, useRef } from 'react'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
function on<T extends Document | EventTarget | HTMLElement | Window>(
obj: null | T,
...args: [string, Function | null, ...any] | Parameters<T['addEventListener']>
...args: [string, (() => void) | null, ...any] | Parameters<T['addEventListener']>
): void {
if (obj && obj.addEventListener) {
obj.addEventListener(...(args as Parameters<HTMLElement['addEventListener']>))
@@ -17,7 +19,7 @@ function on<T extends Document | EventTarget | HTMLElement | Window>(
function off<T extends Document | EventTarget | HTMLElement | Window>(
obj: null | T,
...args: [string, Function | null, ...any] | Parameters<T['removeEventListener']>
...args: [string, (() => void) | null, ...any] | Parameters<T['removeEventListener']>
): void {
if (obj && obj.removeEventListener) {
obj.removeEventListener(...(args as Parameters<HTMLElement['removeEventListener']>))
@@ -72,6 +74,7 @@ export const usePreventLeave = ({
}) => {
// check when page is about to be reloaded
useBeforeUnload(prevent, message)
const { startRouteTransition } = useRouteTransition()
const router = useRouter()
const cancelledURL = useRef<string>('')
@@ -104,6 +107,7 @@ export const usePreventLeave = ({
}
return element as HTMLAnchorElement
}
function handleClick(event: MouseEvent) {
try {
const target = event.target as HTMLElement
@@ -148,7 +152,8 @@ export const usePreventLeave = ({
if (onAccept) {
onAccept()
}
router.push(cancelledURL.current)
startRouteTransition(() => router.push(cancelledURL.current))
}
}, [hasAccepted, onAccept, router])
}, [hasAccepted, onAccept, router, startRouteTransition])
}

View File

@@ -0,0 +1,101 @@
// Format function modified from nodejs
// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.
import type { UrlObject } from 'url'
const slashedProtocols = /https?|ftp|gopher|file/
function stringifyUrlQueryParam(param: unknown): string {
if (
typeof param === 'string' ||
(typeof param === 'number' && !isNaN(param)) ||
typeof param === 'boolean'
) {
return String(param)
} else {
return ''
}
}
export function urlQueryToSearchParams(urlQuery: UrlObject['query']): URLSearchParams {
const result = new URLSearchParams()
Object.entries(urlQuery).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => result.append(key, stringifyUrlQueryParam(item)))
} else {
result.set(key, stringifyUrlQueryParam(value))
}
})
return result
}
export function formatUrl(urlObj: UrlObject) {
let { auth } = urlObj
const { hostname } = urlObj
let protocol = urlObj.protocol || ''
let pathname = urlObj.pathname || ''
let hash = urlObj.hash || ''
let query = urlObj.query || ''
let host: false | string = false
auth = auth ? encodeURIComponent(auth).replace(/%3A/i, ':') + '@' : ''
if (urlObj.host) {
host = auth + urlObj.host
} else if (hostname) {
host = auth + (~hostname.indexOf(':') ? `[${hostname}]` : hostname)
if (urlObj.port) {
host += ':' + urlObj.port
}
}
if (query && typeof query === 'object') {
query = String(urlQueryToSearchParams(query))
}
let search = urlObj.search || (query && `?${query}`) || ''
if (protocol && !protocol.endsWith(':')) {
protocol += ':'
}
if (urlObj.slashes || ((!protocol || slashedProtocols.test(protocol)) && host !== false)) {
host = '//' + (host || '')
if (pathname && pathname[0] !== '/') {
pathname = '/' + pathname
}
} else if (!host) {
host = ''
}
if (hash && hash[0] !== '#') {
hash = '#' + hash
}
if (search && search[0] !== '?') {
search = '?' + search
}
pathname = pathname.replace(/[?#]/g, encodeURIComponent)
search = search.replace('#', '%23')
return `${protocol}${host}${pathname}${search}${hash}`
}

View File

@@ -0,0 +1,66 @@
'use client'
import NextLinkImport from 'next/link.js'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { formatUrl } from './formatUrl.js'
const NextLink = (NextLinkImport.default ||
NextLinkImport) as unknown as typeof NextLinkImport.default
// Copied from https://github.com/vercel/next.js/blob/canary/packages/next/src/client/link.tsx#L180-L191
function isModifiedEvent(event: React.MouseEvent): boolean {
const eventTarget = event.currentTarget as HTMLAnchorElement | SVGAElement
const target = eventTarget.getAttribute('target')
return (
(target && target !== '_self') ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey || // triggers resource download
(event.nativeEvent && event.nativeEvent.which === 2)
)
}
export const Link: React.FC<Parameters<typeof NextLink>[0]> = ({
children,
href,
onClick,
ref,
replace,
scroll,
...rest
}) => {
const router = useRouter()
const { startRouteTransition } = useRouteTransition()
return (
<NextLink
href={href}
onClick={(e) => {
if (isModifiedEvent(e)) {
return
}
if (onClick) {
onClick(e)
}
startRouteTransition(() => {
const url = typeof href === 'string' ? href : formatUrl(href)
if (replace) {
void router.replace(url, { scroll })
} else {
void router.push(url, { scroll })
}
})
}}
ref={ref}
{...rest}
>
{children}
</NextLink>
)
}

View File

@@ -1,10 +1,10 @@
'use client'
import type { ElementType, HTMLAttributes } from 'react'
import LinkWithDefault from 'next/link.js'
const Link = LinkWithDefault.default
import React from 'react' // TODO: abstract this out to support all routers
import { Link } from '../Link/index.js'
export type PillProps = {
alignIcon?: 'left' | 'right'
'aria-checked'?: boolean

View File

@@ -1,13 +1,11 @@
'use client'
import type { LinkProps } from 'next/link.js'
import LinkImport from 'next/link.js'
import * as React from 'react'
import { Link } from '../../Link/index.js'
import './index.scss'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
const baseClass = 'popup-button-list'
export const ButtonGroup: React.FC<{

View File

@@ -1,12 +1,12 @@
'use client'
import { Modal, useModal } from '@faceless-ui/modal'
// TODO: abstract the `next/navigation` dependency out from this component
import { useRouter } from 'next/navigation.js'
import React from 'react'
import { Button } from '../../elements/Button/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import './index.scss'
@@ -28,8 +28,9 @@ export const StayLoggedInModal: React.FC = () => {
routes: { admin: adminRoute },
} = config
const { toggleModal } = useModal()
const { closeModal } = useModal()
const { t } = useTranslation()
const { startRouteTransition } = useRouteTransition()
return (
<Modal className={baseClass} slug={stayLoggedInModalSlug}>
@@ -42,12 +43,15 @@ export const StayLoggedInModal: React.FC = () => {
<Button
buttonStyle="secondary"
onClick={() => {
toggleModal(stayLoggedInModalSlug)
closeModal(stayLoggedInModalSlug)
startRouteTransition(() =>
router.push(
formatAdminURL({
adminRoute,
path: logoutRoute,
}),
),
)
}}
size="large"
@@ -57,7 +61,7 @@ export const StayLoggedInModal: React.FC = () => {
<Button
onClick={() => {
refreshCookie()
toggleModal(stayLoggedInModalSlug)
closeModal(stayLoggedInModalSlug)
}}
size="large"
>

View File

@@ -2,16 +2,15 @@
import type { ClientCollectionConfig, DefaultCellComponentProps, UploadFieldClient } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import LinkImport from 'next/link.js'
import { fieldAffectsData, fieldIsID } from 'payload/shared'
import React from 'react' // TODO: abstract this out to support all routers
import { useConfig } from '../../../providers/Config/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { formatAdminURL } from '../../../utilities/formatAdminURL.js'
import { Link } from '../../Link/index.js'
import { CodeCell } from './fields/Code/index.js'
import { cellComponents } from './fields/index.js'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
const {
@@ -34,7 +33,7 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
getEntityConfig,
} = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const collectionConfig = getEntityConfig({ collectionSlug })
const classNameFromConfigContext = admin && 'className' in admin ? admin.className : undefined

View File

@@ -82,11 +82,13 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
label: getTranslation(successLabel, i18n),
}),
)
if (json?.errors.length > 0) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
}
router.replace(
qs.stringify(
{
@@ -96,6 +98,7 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
{ addQueryPrefix: true },
),
)
clearRouteCache() // Use clearRouteCache instead of router.refresh, as we only need to clear the cache if the user has route caching enabled - clearRouteCache checks for this
return null
}

View File

@@ -23,6 +23,7 @@ export { useEffectEvent } from '../../hooks/useEffectEvent.js'
export { useUseTitleField } from '../../hooks/useUseAsTitle.js'
// elements
export { Link } from '../../elements/Link/index.js'
export { LeaveWithoutSaving } from '../../elements/LeaveWithoutSaving/index.js'
export { DocumentTakeOver } from '../../elements/DocumentTakeOver/index.js'
export { DocumentLocked } from '../../elements/DocumentLocked/index.js'
@@ -246,6 +247,11 @@ export type { UserWithToken } from '../../providers/Auth/index.js'
export { ClientFunctionProvider, useClientFunctions } from '../../providers/ClientFunction/index.js'
export { useAddClientFunction } from '../../providers/ClientFunction/index.js'
export { ProgressBar } from '../../providers/RouteTransition/ProgressBar/index.js'
export {
RouteTransitionProvider,
useRouteTransition,
} from '../../providers/RouteTransition/index.js'
export { ConfigProvider, useConfig } from '../../providers/Config/index.js'
export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js'
export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js'

View File

@@ -10,7 +10,7 @@ import {
reduceFieldsToValues,
wait,
} from 'payload/shared'
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { toast } from 'sonner'
import type {
@@ -29,6 +29,7 @@ import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useOperation } from '../../providers/Operation/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
@@ -88,6 +89,7 @@ export const Form: React.FC<FormProps> = (props) => {
const operation = useOperation()
const { getFormState } = useServerFunctions()
const { startRouteTransition } = useRouteTransition()
const { config } = useConfig()
@@ -363,7 +365,7 @@ export const Form: React.FC<FormProps> = (props) => {
setProcessing(false)
if (redirect) {
router.push(redirect)
startRouteTransition(() => router.push(redirect))
} else if (!disableSuccessStatus) {
successToast(json.message || t('general:submissionSuccessful'))
}

View File

@@ -13,6 +13,7 @@ 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'
import { useRouteTransition } from '../RouteTransition/index.js'
export type UserWithToken<T = ClientUser> = {
exp: number
@@ -74,28 +75,22 @@ export function AuthProvider({
const [lastLocationChange, setLastLocationChange] = useState(0)
const debouncedLocationChange = useDebounce(lastLocationChange, 10000)
const refreshTokenTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null)
const { startRouteTransition } = useRouteTransition()
const id = user?.id
const redirectToInactivityRoute = useCallback(() => {
if (window.location.pathname.startsWith(adminRoute)) {
const redirectParam = `?redirect=${encodeURIComponent(window.location.pathname)}`
startRouteTransition(() =>
router.replace(
formatAdminURL({
adminRoute,
path: `${logoutInactivityRoute}${redirectParam}`,
path: `${logoutInactivityRoute}${window.location.pathname.startsWith(adminRoute) ? `?redirect=${encodeURIComponent(window.location.pathname)}` : ''}`,
}),
),
)
} else {
router.replace(
formatAdminURL({
adminRoute,
path: logoutInactivityRoute,
}),
)
}
closeAllModals()
}, [router, adminRoute, logoutInactivityRoute, closeAllModals])
}, [router, adminRoute, logoutInactivityRoute, closeAllModals, startRouteTransition])
const revokeTokenAndExpire = useCallback(() => {
setTokenInMemory(undefined)

View File

@@ -28,6 +28,7 @@ import { LocaleProvider } from '../Locale/index.js'
import { ParamsProvider } from '../Params/index.js'
import { PreferencesProvider } from '../Preferences/index.js'
import { RouteCache } from '../RouteCache/index.js'
import { RouteTransitionProvider } from '../RouteTransition/index.js'
import { SearchParamsProvider } from '../SearchParams/index.js'
import { ServerFunctionsProvider } from '../ServerFunctions/index.js'
import { ThemeProvider } from '../Theme/index.js'
@@ -73,6 +74,7 @@ export const RootProvider: React.FC<Props> = ({
return (
<Fragment>
<ServerFunctionsProvider serverFunction={serverFunction}>
<RouteTransitionProvider>
<RouteCacheComponent>
<ConfigProvider config={config}>
<ClientFunctionProvider>
@@ -124,6 +126,7 @@ export const RootProvider: React.FC<Props> = ({
</ClientFunctionProvider>
</ConfigProvider>
</RouteCacheComponent>
</RouteTransitionProvider>
</ServerFunctionsProvider>
<ToastContainer />
</Fragment>

View File

@@ -0,0 +1,21 @@
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 2px;
z-index: 9999;
opacity: 1;
&__progress {
height: 100%;
background-color: var(--theme-elevation-1000);
transition: width ease-in var(--transition-duration);
}
&--fade-out {
opacity: 0;
transition: opacity linear var(--transition-duration);
transition-delay: var(--transition-duration);
}
}

View File

@@ -0,0 +1,67 @@
'use client'
import React, { useEffect } from 'react'
import { useRouteTransition } from '../index.js'
import './index.scss'
const transitionDuration = 200
const baseClass = 'progress-bar'
/**
* Renders a progress bar that shows the progress of a route transition.
* Place this at the root of your application, inside of the `RouteTransitionProvider`.
* When a transition is triggered, the progress bar will show the progress of that transition and exit when the transition is complete.
* @returns A progress bar that shows the progress of a route transition
* @example
* import { RouteTransitionProvider, ProgressBar, Link } from '@payloadcms/ui'
* const App = () => (
* <RouteTransitionProvider>
* <ProgressBar />
* <Link href="/somewhere">Go Somewhere</Link>
* </RouteTransitionProvider>
*/
export const ProgressBar = () => {
const { isTransitioning, transitionProgress } = useRouteTransition()
const [progressToShow, setProgressToShow] = React.useState<null | number>(null)
useEffect(() => {
let clearTimerID: NodeJS.Timeout
if (isTransitioning) {
setProgressToShow(transitionProgress)
} else {
// Fast forward to 100% when the transition is complete
// Then fade out the progress bar directly after
setProgressToShow(1)
// Wait for CSS transition to finish before hiding the progress bar
// This includes both the fast-forward to 100% and the subsequent fade-out
clearTimerID = setTimeout(() => {
setProgressToShow(null)
}, transitionDuration * 2)
}
return () => clearTimeout(clearTimerID)
}, [isTransitioning, transitionProgress])
if (typeof progressToShow === 'number') {
return (
<div
className={[baseClass, progressToShow === 1 && `${baseClass}--fade-out`]
.filter(Boolean)
.join(' ')}
style={{
// @ts-expect-error - TS doesn't like custom CSS properties
'--transition-duration': `${transitionDuration}ms`,
}}
>
<div
className={`${baseClass}__progress`}
style={{
width: `${(progressToShow || 0) * 100}%`,
}}
/>
</div>
)
}
}

View File

@@ -0,0 +1,115 @@
'use client'
import React, { startTransition, useCallback, useEffect, useOptimistic, useRef } from 'react'
/**
* Route transitions are useful in showing immediate visual feedback to the user when navigating between pages. This is especially useful on slow networks when navigating to data heavy or process intensive pages.
* To use route transitions, place the `RouteTransitionProvider` at the root of your application, outside of the `ProgressBar` component.
* To trigger a route transition, use the `Link` component from `@payloadcms/ui`,
* or wrap a callback function with the `startRouteTransition` method.
* To gain access to the `RouteTransitionContext`, call the `useRouteTransition` hook in your component.
* @returns A context provider with methods and state for transitioning between routes, including `isTransitioning`, `startRouteTransition`, and `transitionProgress`.
* @example
* import { RouteTransitionProvider, ProgressBar, Link } from '@payloadcms/ui'
* const App = () => (
* <RouteTransitionProvider>
* <ProgressBar />
* <Link href="/somewhere">Go Somewhere</Link>
* </RouteTransitionProvider>
* )
*/
export const RouteTransitionProvider: React.FC<RouteTransitionProps> = ({ children }) => {
const [isTransitioning, setIsTransitioning] = useOptimistic(false)
const [transitionProgress, setTransitionProgress] = React.useState<number>(0)
const transitionProgressRef = useRef(transitionProgress)
const timerID = useRef(null)
const initiateProgress = useCallback(() => {
// randomly update progress at random times, never reaching 100%
timerID.current = setInterval(() => {
const projectedProgress =
transitionProgressRef.current + Math.random() * 0.1 * (1 - transitionProgressRef.current)
const newProgress = projectedProgress >= 1 ? 1 : projectedProgress
setTransitionProgress(newProgress)
transitionProgressRef.current = newProgress
}, 250) // every n ms, update progress
}, [])
useEffect(() => {
setTransitionProgress(0)
transitionProgressRef.current = 0
if (isTransitioning) {
initiateProgress()
} else {
if (timerID.current) {
clearInterval(timerID.current)
}
}
}, [isTransitioning, initiateProgress])
const startRouteTransition: StartRouteTransition = useCallback(
(callback?: () => void) => {
startTransition(() => {
setIsTransitioning(true)
if (typeof callback === 'function') {
callback()
}
})
},
[setIsTransitioning],
)
return (
<RouteTransitionContext.Provider
value={{ isTransitioning, startRouteTransition, transitionProgress }}
>
{children}
</RouteTransitionContext.Provider>
)
}
type RouteTransitionProps = {
children: React.ReactNode
}
type StartRouteTransition = (callback?: () => void) => void
type RouteTransitionContextValue = {
isTransitioning: boolean
startRouteTransition: StartRouteTransition
transitionProgress: number
}
const RouteTransitionContext = React.createContext<RouteTransitionContextValue>({
isTransitioning: false,
startRouteTransition: () => undefined,
transitionProgress: 0,
})
/**
* Use this hook to access the `RouteTransitionContext` provided by the `RouteTransitionProvider`.
* To start a transition, fire the `startRouteTransition` method with a provided callback to run while the transition takes place.
* @returns The `RouteTransitionContext` needed for transitioning between routes, including `isTransitioning`, `startRouteTransition`, and `transitionProgress`.
* @example
* 'use client'
* import React, { useCallback } from 'react'
* import { useTransition } from '@payloadcms/ui'
* import { useRouter } from 'next/navigation'
*
* const MyComponent: React.FC = () => {
* const router = useRouter()
* const { startRouteTransition } = useRouteTransition()
*
* const redirectSomewhere = useCallback(() => {
* startRouteTransition(() => router.push('/somewhere'))
* }, [startRouteTransition, router])
*
* // ...
* }
*/
export const useRouteTransition = () => React.useContext(RouteTransitionContext)

View File

@@ -2,15 +2,12 @@ import type {
ClientCollectionConfig,
CollectionConfig,
Field,
FilterOptionsProps,
ImportMap,
ListPreferences,
PaginatedDocs,
Payload,
SanitizedCollectionConfig,
Where,
} from 'payload'
import type { MarkOptional } from 'ts-essentials'
import { getTranslation, type I18nClient } from '@payloadcms/translations'
import { fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared'
@@ -51,50 +48,6 @@ export const renderFilters = (
new Map() as Map<string, React.ReactNode>,
)
// export const resolveFilterOptions = async ({
// fields,
// relationTo,
// req,
// user,
// }: { fields: Field[] } & MarkOptional<
// FilterOptionsProps,
// 'blockData' | 'data' | 'id' | 'siblingData'
// >): Promise<Map<string, Where>> => {
// const acc = new Map<string, Where>()
// for (const field of fields) {
// if (fieldIsHiddenOrDisabled(field)) {
// continue
// }
// if ('name' in field && 'filterOptions' in field && field.filterOptions) {
// let resolvedFilterOption = {} as Where
// if (typeof field.filterOptions === 'function') {
// const result = await field.filterOptions({
// id: undefined,
// blockData: undefined,
// data: {}, // use empty object to prevent breaking queries when accessing properties of data
// relationTo,
// req,
// siblingData: {}, // use empty object to prevent breaking queries when accessing properties of siblingData
// user,
// })
// if (result && typeof result === 'object') {
// resolvedFilterOption = result
// }
// } else {
// resolvedFilterOption = field.filterOptions
// }
// acc.set(field.name, resolvedFilterOption)
// }
// }
// return acc
// }
export const renderTable = ({
clientCollectionConfig,
collectionConfig,

View File

@@ -23,6 +23,7 @@ import { useDocumentEvents } from '../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { OperationProvider } from '../../providers/Operation/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useUploadEdits } from '../../providers/UploadEdits/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
@@ -113,6 +114,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
const { reportUpdate } = useDocumentEvents()
const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions()
const { startRouteTransition } = useRouteTransition()
const abortOnChangeRef = useRef<AbortController>(null)
const abortOnSaveRef = useRef<AbortController>(null)
@@ -258,7 +260,8 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
adminRoute,
path: `/collections/${collectionSlug}/${document?.id}${locale ? `?locale=${locale}` : ''}`,
})
router.push(redirectRoute)
startRouteTransition(() => router.push(redirectRoute))
} else {
resetUploadEdits()
}

View File

@@ -1,13 +1,12 @@
import type { I18nClient, TFunction } from '@payloadcms/translations'
import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import LinkImport from 'next/link.js'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import { Button } from '../../../elements/Button/index.js'
import { Link } from '../../../elements/Link/index.js'
import { useListDrawerContext } from '../../../elements/ListDrawer/Provider.js'
import { ListSelection } from '../../../elements/ListSelection/index.js'
import { Pill } from '../../../elements/Pill/index.js'

View File

@@ -3,7 +3,6 @@
import type { ListPreferences, ResolvedFilterOptions } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import LinkImport from 'next/link.js'
import { useRouter } from 'next/navigation.js'
import { formatFilesize, isNumber } from 'payload/shared'
import React, { Fragment, useEffect, useState } from 'react'
@@ -15,6 +14,7 @@ import { Button } from '../../elements/Button/index.js'
import { DeleteMany } from '../../elements/DeleteMany/index.js'
import { EditMany } from '../../elements/EditMany/index.js'
import { Gutter } from '../../elements/Gutter/index.js'
import { Link } from '../../elements/Link/index.js'
import { ListControls } from '../../elements/ListControls/index.js'
import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js'
import { ListSelection } from '../../elements/ListSelection/index.js'
@@ -40,7 +40,6 @@ import { ListHeader } from './ListHeader/index.js'
import './index.scss'
const baseClass = 'collection-list'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export type ListViewSlots = {
AfterList?: React.ReactNode

View File

@@ -6,6 +6,60 @@
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
@@ -13,7 +67,6 @@ export interface Config {
collections: {
posts: Post;
media: Media;
test: Test;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -23,7 +76,6 @@ export interface Config {
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
test: TestSelect<false> | TestSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -135,28 +187,6 @@ export interface Media {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "test".
*/
export interface Test {
id: string;
test_array?:
| {
test_text?:
| {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'test_text';
}[]
| null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -189,10 +219,6 @@ export interface PayloadLockedDocument {
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'test';
value: string | Test;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -301,30 +327,6 @@ export interface MediaSelect<T extends boolean = true> {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "test_select".
*/
export interface TestSelect<T extends boolean = true> {
test_array?:
| T
| {
test_text?:
| T
| {
test_text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
};
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
@@ -64,6 +64,7 @@ const dirname = path.resolve(currentFolder, '../../')
describe('General', () => {
let page: Page
let postsUrl: AdminUrlUtil
let context: BrowserContext
let geoUrl: AdminUrlUtil
let notInViewUrl: AdminUrlUtil
let globalURL: AdminUrlUtil
@@ -89,7 +90,7 @@ describe('General', () => {
customViewsURL = new AdminUrlUtil(serverURL, customViews2CollectionSlug)
disableDuplicateURL = new AdminUrlUtil(serverURL, disableDuplicateSlug)
const context = await browser.newContext()
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -938,6 +939,14 @@ describe('General', () => {
await expect(toast).toBeVisible()
})
})
describe('progress bar', () => {
test('should show progress bar on page navigation', async () => {
await page.goto(postsUrl.admin)
await page.locator('.dashboard__card-list .card').first().click()
await expect(page.locator('.progress-bar')).toBeVisible()
})
})
})
async function deleteAllPosts() {

View File

@@ -6,6 +6,60 @@
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;