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:
@@ -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])
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(' ')}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) : []
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
101
packages/ui/src/elements/Link/formatUrl.ts
Normal file
101
packages/ui/src/elements/Link/formatUrl.ts
Normal 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}`
|
||||
}
|
||||
66
packages/ui/src/elements/Link/index.tsx
Normal file
66
packages/ui/src/elements/Link/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
115
packages/ui/src/providers/RouteTransition/index.tsx
Normal file
115
packages/ui/src/providers/RouteTransition/index.tsx
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user