refactor(ui): deprecates Link props (#11155)

Deprecates all cases where `Link` could be sent as a prop. This was a
relic from the past, where we attempted to make our UI library
router-agnostic. This was a pipe dream and created more problems than it
solved, for example the logout button was missing this prop, causing it
to render an anchor tag and perform a hard navigation (caught in #9275).

Does so in a non-breaking way, where these props are now optional and
simply unused, as opposed to removing them outright.
This commit is contained in:
Jacob Fletcher
2025-02-13 11:10:57 -05:00
committed by GitHub
parent 3f550bc0ec
commit cd1117515b
16 changed files with 62 additions and 67 deletions

View File

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

View File

@@ -17,7 +17,12 @@ export type DashboardProps = {
lockDuration?: number lockDuration?: number
slug: string slug: string
}> }>
Link: React.ComponentType<any> /**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
Link?: React.ComponentType
navGroups?: ReturnType<typeof groupNavItems> navGroups?: ReturnType<typeof groupNavItems>
permissions: SanitizedPermissions permissions: SanitizedPermissions
visibleEntities: VisibleEntities visibleEntities: VisibleEntities
@@ -28,7 +33,6 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
globalData, globalData,
i18n, i18n,
i18n: { t }, i18n: { t },
Link,
locale, locale,
navGroups, navGroups,
params, params,
@@ -146,7 +150,6 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
el="link" el="link"
icon="plus" icon="plus"
iconStyle="with-border" iconStyle="with-border"
Link={Link}
round round
to={createHREF} to={createHREF}
/> />
@@ -155,7 +158,6 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
buttonAriaLabel={buttonAriaLabel} buttonAriaLabel={buttonAriaLabel}
href={href} href={href}
id={`card-${slug}`} id={`card-${slug}`}
Link={Link}
title={getTranslation(label, i18n)} title={getTranslation(label, i18n)}
titleAs="h3" titleAs="h3"
/> />

View File

@@ -1,7 +1,7 @@
import type { EntityToGroup } from '@payloadcms/ui/shared' import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { AdminViewProps } from 'payload' import type { AdminViewProps } from 'payload'
import { HydrateAuthProvider, Link, SetStepNav } from '@payloadcms/ui' import { HydrateAuthProvider, SetStepNav } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { EntityType, groupNavItems } from '@payloadcms/ui/shared' import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
@@ -107,7 +107,6 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
<SetStepNav nav={[]} /> <SetStepNav nav={[]} />
{RenderServerComponent({ {RenderServerComponent({
clientProps: { clientProps: {
Link,
locale, locale,
}, },
Component: config.admin?.components?.views?.dashboard?.Component, Component: config.admin?.components?.views?.dashboard?.Component,

View File

@@ -52,7 +52,7 @@ export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult })
} }
heading={i18n.t('authentication:alreadyLoggedIn')} heading={i18n.t('authentication:alreadyLoggedIn')}
/> />
<Button buttonStyle="secondary" el="link" Link={Link} size="large" to={adminRoute}> <Button buttonStyle="secondary" el="link" size="large" to={adminRoute}>
{i18n.t('general:backToDashboard')} {i18n.t('general:backToDashboard')}
</Button> </Button>
</Fragment> </Fragment>

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { import {
Button, Button,
Link,
LoadingOverlay, LoadingOverlay,
toast, toast,
useAuth, useAuth,
@@ -69,7 +68,7 @@ export const LogoutClient: React.FC<{
return ( return (
<div className={`${baseClass}__wrap`}> <div className={`${baseClass}__wrap`}>
<h2>{t('authentication:loggedOutInactivity')}</h2> <h2>{t('authentication:loggedOutInactivity')}</h2>
<Button buttonStyle="secondary" el="link" Link={Link} size="large" url={loginRoute}> <Button buttonStyle="secondary" el="link" size="large" url={loginRoute}>
{t('authentication:logBackIn')} {t('authentication:logBackIn')}
</Button> </Button>
</div> </div>

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import { Button, Gutter, Link, useConfig, useStepNav, useTranslation } from '@payloadcms/ui' import { Button, Gutter, useConfig, useStepNav, useTranslation } from '@payloadcms/ui'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import './index.scss' import './index.scss'
@@ -39,13 +39,7 @@ export const NotFoundClient: React.FC<{
<h1>{t('general:nothingFound')}</h1> <h1>{t('general:nothingFound')}</h1>
<p>{t('general:sorryNotFound')}</p> <p>{t('general:sorryNotFound')}</p>
</div> </div>
<Button <Button className={`${baseClass}__button`} el="link" size="large" to={adminRoute}>
className={`${baseClass}__button`}
el="link"
Link={Link}
size="large"
to={adminRoute}
>
{t('general:backToDashboard')} {t('general:backToDashboard')}
</Button> </Button>
</Gutter> </Gutter>

View File

@@ -5,8 +5,8 @@ import { formatAdminURL, Translation } from '@payloadcms/ui/shared'
import React from 'react' import React from 'react'
import { FormHeader } from '../../elements/FormHeader/index.js' import { FormHeader } from '../../elements/FormHeader/index.js'
import './index.scss'
import { ResetPasswordForm } from './ResetPasswordForm/index.js' import { ResetPasswordForm } from './ResetPasswordForm/index.js'
import './index.scss'
export const resetPasswordBaseClass = 'reset-password' export const resetPasswordBaseClass = 'reset-password'
@@ -57,7 +57,7 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
} }
heading={i18n.t('authentication:alreadyLoggedIn')} heading={i18n.t('authentication:alreadyLoggedIn')}
/> />
<Button buttonStyle="secondary" el="link" Link={Link} size="large" to={adminRoute}> <Button buttonStyle="secondary" el="link" size="large" to={adminRoute}>
{i18n.t('general:backToDashboard')} {i18n.t('general:backToDashboard')}
</Button> </Button>
</div> </div>

View File

@@ -1,6 +1,6 @@
import type { AdminViewComponent, PayloadServerReactComponent } from 'payload' import type { AdminViewComponent, PayloadServerReactComponent } from 'payload'
import { Button, Link } from '@payloadcms/ui' import { Button } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared' import { formatAdminURL } from '@payloadcms/ui/shared'
import React from 'react' import React from 'react'
@@ -41,7 +41,6 @@ export const UnauthorizedView: PayloadServerReactComponent<AdminViewComponent> =
<Button <Button
className={`${baseClass}__button`} className={`${baseClass}__button`}
el="link" el="link"
Link={Link}
size="large" size="large"
to={formatAdminURL({ to={formatAdminURL({
adminRoute, adminRoute,

View File

@@ -13,8 +13,8 @@ import { LocalizerLabel } from '../Localizer/LocalizerLabel/index.js'
import { useNav } from '../Nav/context.js' import { useNav } from '../Nav/context.js'
import { NavToggler } from '../Nav/NavToggler/index.js' import { NavToggler } from '../Nav/NavToggler/index.js'
import { RenderCustomComponent } from '../RenderCustomComponent/index.js' import { RenderCustomComponent } from '../RenderCustomComponent/index.js'
import './index.scss'
import { StepNav } from '../StepNav/index.js' import { StepNav } from '../StepNav/index.js'
import './index.scss'
const baseClass = 'app-header' const baseClass = 'app-header'
@@ -73,7 +73,7 @@ export function AppHeader({ CustomAvatar, CustomIcon }: Props) {
</NavToggler> </NavToggler>
<div className={`${baseClass}__controls-wrapper`}> <div className={`${baseClass}__controls-wrapper`}>
<div className={`${baseClass}__step-nav-wrapper`}> <div className={`${baseClass}__step-nav-wrapper`}>
<StepNav className={`${baseClass}__step-nav`} CustomIcon={CustomIcon} Link={Link} /> <StepNav className={`${baseClass}__step-nav`} CustomIcon={CustomIcon} />
</div> </div>
<div className={`${baseClass}__actions-wrapper`}> <div className={`${baseClass}__actions-wrapper`}>
<div className={`${baseClass}__actions`} ref={customControlsRef}> <div className={`${baseClass}__actions`} ref={customControlsRef}>

View File

@@ -9,6 +9,7 @@ import { LinkIcon } from '../../icons/Link/index.js'
import { PlusIcon } from '../../icons/Plus/index.js' import { PlusIcon } from '../../icons/Plus/index.js'
import { SwapIcon } from '../../icons/Swap/index.js' import { SwapIcon } from '../../icons/Swap/index.js'
import { XIcon } from '../../icons/X/index.js' import { XIcon } from '../../icons/X/index.js'
import { Link } from '../Link/index.js'
import { Popup } from '../Popup/index.js' import { Popup } from '../Popup/index.js'
import { Tooltip } from '../Tooltip/index.js' import { Tooltip } from '../Tooltip/index.js'
import './index.scss' import './index.scss'
@@ -61,7 +62,6 @@ export const Button: React.FC<Props> = (props) => {
icon, icon,
iconPosition = 'right', iconPosition = 'right',
iconStyle = 'without-border', iconStyle = 'without-border',
Link,
newTab, newTab,
onClick, onClick,
onMouseDown, onMouseDown,
@@ -143,26 +143,24 @@ export const Button: React.FC<Props> = (props) => {
break break
case 'link': case 'link':
if (!Link) {
console.error('Link is required when using el="link"', children)
return null
}
let LinkTag = Link // eslint-disable-line no-case-declarations
if (disabled) { if (disabled) {
LinkTag = 'div' buttonElement = (
} else { <div {...buttonProps}>
prefetch = false <ButtonContents icon={icon} showTooltip={showTooltip} tooltip={tooltip}>
{children}
</ButtonContents>
</div>
)
} }
buttonElement = ( buttonElement = (
<LinkTag {...buttonProps} href={to || url} prefetch={prefetch} to={to || url}> <Link {...buttonProps} href={to || url} prefetch={prefetch}>
<ButtonContents icon={icon} showTooltip={showTooltip} tooltip={tooltip}> <ButtonContents icon={icon} showTooltip={showTooltip} tooltip={tooltip}>
{children} {children}
</ButtonContents> </ButtonContents>
</LinkTag> </Link>
) )
break break
default: default:

View File

@@ -22,6 +22,11 @@ export type Props = {
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
iconStyle?: 'none' | 'with-border' | 'without-border' iconStyle?: 'none' | 'with-border' | 'without-border'
id?: string id?: string
/**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
Link?: React.ElementType Link?: React.ElementType
newTab?: boolean newTab?: boolean
onClick?: (event: MouseEvent) => void onClick?: (event: MouseEvent) => void

View File

@@ -1,5 +1,4 @@
'use client' 'use client'
import type { ElementType } from 'react'
import React from 'react' import React from 'react'
@@ -11,16 +10,21 @@ export type Props = {
buttonAriaLabel?: string buttonAriaLabel?: string
href?: string href?: string
id?: string id?: string
Link?: ElementType /**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
Link?: React.ElementType
onClick?: () => void onClick?: () => void
title: string title: string
titleAs?: ElementType titleAs?: React.ElementType
} }
const baseClass = 'card' const baseClass = 'card'
export const Card: React.FC<Props> = (props) => { export const Card: React.FC<Props> = (props) => {
const { id, actions, buttonAriaLabel, href, Link, onClick, title, titleAs } = props const { id, actions, buttonAriaLabel, href, onClick, title, titleAs } = props
const classes = [baseClass, id, (onClick || href) && `${baseClass}--has-onclick`] const classes = [baseClass, id, (onClick || href) && `${baseClass}--has-onclick`]
.filter(Boolean) .filter(Boolean)
@@ -38,7 +42,6 @@ export const Card: React.FC<Props> = (props) => {
buttonStyle="none" buttonStyle="none"
className={`${baseClass}__click`} className={`${baseClass}__click`}
el="link" el="link"
Link={Link}
onClick={onClick} onClick={onClick}
to={href} to={href}
/> />

View File

@@ -5,13 +5,19 @@ import { LogOutIcon } from '../../icons/LogOut/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Link } from '../Link/index.js'
const baseClass = 'nav' const baseClass = 'nav'
export const Logout: React.FC<{ export const Logout: React.FC<{
/**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
Link?: React.ComponentType Link?: React.ComponentType
tabIndex?: number tabIndex?: number
}> = ({ Link, tabIndex = 0 }) => { }> = ({ tabIndex = 0 }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { config } = useConfig() const { config } = useConfig()
@@ -23,7 +29,6 @@ export const Logout: React.FC<{
} = config } = config
const basePath = process.env.NEXT_BASE_PATH ?? '' const basePath = process.env.NEXT_BASE_PATH ?? ''
const LinkElement = Link || 'a'
const props = { const props = {
'aria-label': t('authentication:logOut'), 'aria-label': t('authentication:logOut'),
@@ -34,7 +39,7 @@ export const Logout: React.FC<{
} }
return ( return (
<LinkElement <Link
{...props} {...props}
href={formatAdminURL({ href={formatAdminURL({
adminRoute, adminRoute,
@@ -43,6 +48,6 @@ export const Logout: React.FC<{
})} })}
> >
<LogOutIcon /> <LogOutIcon />
</LinkElement> </Link>
) )
} }

View File

@@ -8,6 +8,7 @@ import type { StepNavItem } from './types.js'
import { PayloadIcon } from '../../graphics/Icon/index.js' import { PayloadIcon } from '../../graphics/Icon/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { Link } from '../Link/index.js'
import { RenderCustomComponent } from '../RenderCustomComponent/index.js' import { RenderCustomComponent } from '../RenderCustomComponent/index.js'
import { StepNavProvider, useStepNav } from './context.js' import { StepNavProvider, useStepNav } from './context.js'
import './index.scss' import './index.scss'
@@ -19,8 +20,13 @@ const baseClass = 'step-nav'
const StepNav: React.FC<{ const StepNav: React.FC<{
readonly className?: string readonly className?: string
readonly CustomIcon?: React.ReactNode readonly CustomIcon?: React.ReactNode
/**
* @deprecated
* This prop is deprecated and will be removed in the next major version.
* Components now import their own `Link` directly from `next/link`.
*/
readonly Link?: React.ComponentType readonly Link?: React.ComponentType
}> = ({ className, CustomIcon, Link }) => { }> = ({ className, CustomIcon }) => {
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { stepNav } = useStepNav() const { stepNav } = useStepNav()
@@ -33,26 +39,15 @@ const StepNav: React.FC<{
const { t } = useTranslation() const { t } = useTranslation()
const LinkElement = Link || 'a'
const baseLinkProps = {
prefetch: Link ? false : undefined,
}
return ( return (
<Fragment> <Fragment>
{stepNav.length > 0 ? ( {stepNav.length > 0 ? (
<nav className={[baseClass, className].filter(Boolean).join(' ')}> <nav className={[baseClass, className].filter(Boolean).join(' ')}>
<LinkElement <Link className={`${baseClass}__home`} href={admin} prefetch={false} tabIndex={0}>
className={`${baseClass}__home`}
href={admin}
tabIndex={0}
{...baseLinkProps}
>
<span title={t('general:dashboard')}> <span title={t('general:dashboard')}>
<RenderCustomComponent CustomComponent={CustomIcon} Fallback={<PayloadIcon />} /> <RenderCustomComponent CustomComponent={CustomIcon} Fallback={<PayloadIcon />} />
</span> </span>
</LinkElement> </Link>
<span>/</span> <span>/</span>
{stepNav.map((item, i) => { {stepNav.map((item, i) => {
const StepLabel = getTranslation(item.label, i18n) const StepLabel = getTranslation(item.label, i18n)
@@ -65,9 +60,9 @@ const StepNav: React.FC<{
) : ( ) : (
<Fragment key={i}> <Fragment key={i}>
{item.url ? ( {item.url ? (
<LinkElement href={item.url} {...baseLinkProps}> <Link href={item.url} prefetch={false}>
<span key={i}>{StepLabel}</span> <span key={i}>{StepLabel}</span>
</LinkElement> </Link>
) : ( ) : (
<span key={i}>{StepLabel}</span> <span key={i}>{StepLabel}</span>
)} )}

View File

@@ -6,7 +6,6 @@ import { getTranslation } from '@payloadcms/translations'
import React from 'react' import React from 'react'
import { Button } from '../../../elements/Button/index.js' import { Button } from '../../../elements/Button/index.js'
import { Link } from '../../../elements/Link/index.js'
import { useListDrawerContext } from '../../../elements/ListDrawer/Provider.js' import { useListDrawerContext } from '../../../elements/ListDrawer/Provider.js'
import { ListSelection } from '../../../elements/ListSelection/index.js' import { ListSelection } from '../../../elements/ListSelection/index.js'
import { Pill } from '../../../elements/Pill/index.js' import { Pill } from '../../../elements/Pill/index.js'
@@ -55,7 +54,6 @@ const DefaultListHeader: React.FC<ListHeaderProps> = ({
})} })}
buttonStyle="pill" buttonStyle="pill"
el={'link'} el={'link'}
Link={Link}
size="small" size="small"
to={newDocumentURL} to={newDocumentURL}
> >

View File

@@ -14,7 +14,6 @@ import { Button } from '../../elements/Button/index.js'
import { DeleteMany } from '../../elements/DeleteMany/index.js' import { DeleteMany } from '../../elements/DeleteMany/index.js'
import { EditMany } from '../../elements/EditMany/index.js' import { EditMany } from '../../elements/EditMany/index.js'
import { Gutter } from '../../elements/Gutter/index.js' import { Gutter } from '../../elements/Gutter/index.js'
import { Link } from '../../elements/Link/index.js'
import { ListControls } from '../../elements/ListControls/index.js' import { ListControls } from '../../elements/ListControls/index.js'
import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js' import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js'
import { ListSelection } from '../../elements/ListSelection/index.js' import { ListSelection } from '../../elements/ListSelection/index.js'
@@ -238,7 +237,7 @@ export const DefaultListView: React.FC<ListViewClientProps> = (props) => {
})} })}
</Button> </Button>
) : ( ) : (
<Button el="link" Link={Link} to={newDocumentURL}> <Button el="link" to={newDocumentURL}>
{i18n.t('general:createNewLabel', { {i18n.t('general:createNewLabel', {
label: getTranslation(labels?.singular, i18n), label: getTranslation(labels?.singular, i18n),
})} })}