fix(next): disables active nav item (#11434)
When visiting a collection's list view, the nav item corresponding to that collection correctly appears in an active state, but is still rendered as an anchor tag. This makes it possible to reload the current page by simply clicking the link, which is a problem because this performs an unnecessary server roundtrip. This is especially apparent when search params exist in the current URL, as the href on the link does not. Unrelated: also cleans up leftover code that was missed in this PR: #11155
This commit is contained in:
@@ -44,23 +44,26 @@ export const DefaultNavClient: React.FC<{
|
|||||||
id = `nav-global-${slug}`
|
id = `nav-global-${slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const LinkElement = Link || 'a'
|
|
||||||
const activeCollection =
|
const activeCollection =
|
||||||
pathname.startsWith(href) && ['/', undefined].includes(pathname[href.length])
|
pathname.startsWith(href) && ['/', undefined].includes(pathname[href.length])
|
||||||
|
|
||||||
|
const Label = (
|
||||||
|
<span className={`${baseClass}__link-label`}>{getTranslation(label, i18n)}</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (activeCollection) {
|
||||||
|
return (
|
||||||
|
<div className={`${baseClass}__link active`} id={id} key={i}>
|
||||||
|
<div className={`${baseClass}__link-indicator`} />
|
||||||
|
{Label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LinkElement
|
<Link className={`${baseClass}__link`} href={href} id={id} key={i} prefetch={false}>
|
||||||
className={[`${baseClass}__link`, activeCollection && `active`]
|
{Label}
|
||||||
.filter(Boolean)
|
</Link>
|
||||||
.join(' ')}
|
|
||||||
href={href}
|
|
||||||
id={id}
|
|
||||||
key={i}
|
|
||||||
prefetch={Link ? false : undefined}
|
|
||||||
>
|
|
||||||
{activeCollection && <div className={`${baseClass}__link-indicator`} />}
|
|
||||||
<span className={`${baseClass}__link-label`}>{getTranslation(label, i18n)}</span>
|
|
||||||
</LinkElement>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</NavGroup>
|
</NavGroup>
|
||||||
|
|||||||
@@ -93,36 +93,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
|
||||||
a {
|
|
||||||
position: relative;
|
|
||||||
padding-block: base(0.125);
|
|
||||||
padding-inline-start: 0;
|
|
||||||
padding-inline-end: base(1.5);
|
|
||||||
display: flex;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:focus:not(:focus-visible) {
|
|
||||||
box-shadow: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
font-weight: normal;
|
|
||||||
padding-left: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
padding-block: base(0.125);
|
||||||
|
padding-inline-start: 0;
|
||||||
|
padding-inline-end: base(1.5);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:focus:not(:focus-visible) {
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
font-weight: normal;
|
||||||
|
padding-left: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.nav__link {
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link-indicator {
|
&__link-indicator {
|
||||||
@@ -148,7 +144,7 @@
|
|||||||
padding: var(--app-header-height) var(--gutter-h) base(2);
|
padding: var(--app-header-height) var(--gutter-h) base(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a {
|
&__link {
|
||||||
font-size: base(0.875);
|
font-size: base(0.875);
|
||||||
line-height: base(1.5);
|
line-height: base(1.5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ export function AppHeader({ CustomAvatar, CustomIcon }: Props) {
|
|||||||
}
|
}
|
||||||
}, [Actions])
|
}, [Actions])
|
||||||
|
|
||||||
const LinkElement = Link || 'a'
|
|
||||||
|
|
||||||
const ActionComponents = Actions ? Object.values(Actions) : []
|
const ActionComponents = Actions ? Object.values(Actions) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,15 +93,15 @@ export function AppHeader({ CustomAvatar, CustomIcon }: Props) {
|
|||||||
{localization && (
|
{localization && (
|
||||||
<LocalizerLabel ariaLabel="invisible" className={`${baseClass}__localizer-spacing`} />
|
<LocalizerLabel ariaLabel="invisible" className={`${baseClass}__localizer-spacing`} />
|
||||||
)}
|
)}
|
||||||
<LinkElement
|
<Link
|
||||||
aria-label={t('authentication:account')}
|
aria-label={t('authentication:account')}
|
||||||
className={`${baseClass}__account`}
|
className={`${baseClass}__account`}
|
||||||
href={formatAdminURL({ adminRoute, path: accountRoute })}
|
href={formatAdminURL({ adminRoute, path: accountRoute })}
|
||||||
prefetch={Link ? false : undefined}
|
prefetch={false}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<RenderCustomComponent CustomComponent={CustomAvatar} Fallback={<Account />} />
|
<RenderCustomComponent CustomComponent={CustomAvatar} Fallback={<Account />} />
|
||||||
</LinkElement>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,21 +28,17 @@ export const Logout: React.FC<{
|
|||||||
routes: { admin: adminRoute },
|
routes: { admin: adminRoute },
|
||||||
} = config
|
} = config
|
||||||
|
|
||||||
const props = {
|
|
||||||
'aria-label': t('authentication:logOut'),
|
|
||||||
className: `${baseClass}__log-out`,
|
|
||||||
prefetch: Link ? false : undefined,
|
|
||||||
tabIndex,
|
|
||||||
title: t('authentication:logOut'),
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
{...props}
|
aria-label={t('authentication:logOut')}
|
||||||
|
className={`${baseClass}__log-out`}
|
||||||
href={formatAdminURL({
|
href={formatAdminURL({
|
||||||
adminRoute,
|
adminRoute,
|
||||||
path: logoutRoute,
|
path: logoutRoute,
|
||||||
})}
|
})}
|
||||||
|
prefetch={false}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
title={t('authentication:logOut')}
|
||||||
>
|
>
|
||||||
<LogOutIcon />
|
<LogOutIcon />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TypeWithID } from 'payload'
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { devUser } from 'credentials.js'
|
import { devUser } from 'credentials.js'
|
||||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||||
|
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { wait } from 'payload/shared'
|
import { wait } from 'payload/shared'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
getRoutes,
|
getRoutes,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
login,
|
login,
|
||||||
openNav,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
@@ -312,7 +312,7 @@ describe('Access Control', () => {
|
|||||||
test('should not show in nav', async () => {
|
test('should not show in nav', async () => {
|
||||||
await page.goto(url.admin)
|
await page.goto(url.admin)
|
||||||
await openNav(page)
|
await openNav(page)
|
||||||
// await expect(page.locator('.nav >> a:has-text("Restricteds")')).toHaveCount(0)
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.nav a', {
|
page.locator('.nav a', {
|
||||||
hasText: exactText('Restricteds'),
|
hasText: exactText('Restricteds'),
|
||||||
@@ -532,7 +532,6 @@ describe('Access Control', () => {
|
|||||||
test('should restrict access based on user settings', async () => {
|
test('should restrict access based on user settings', async () => {
|
||||||
const url = `${serverURL}/admin/globals/settings`
|
const url = `${serverURL}/admin/globals/settings`
|
||||||
await page.goto(url)
|
await page.goto(url)
|
||||||
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain(url)
|
|
||||||
await openNav(page)
|
await openNav(page)
|
||||||
await expect(page.locator('#nav-global-settings')).toBeVisible()
|
await expect(page.locator('#nav-global-settings')).toBeVisible()
|
||||||
await expect(page.locator('#nav-global-test')).toBeHidden()
|
await expect(page.locator('#nav-global-test')).toBeHidden()
|
||||||
|
|||||||
@@ -6,11 +6,66 @@
|
|||||||
* and re-run `payload generate:types` to regenerate this file.
|
* 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 {
|
export interface Config {
|
||||||
auth: {
|
auth: {
|
||||||
users: UserAuthOperations;
|
users: UserAuthOperations;
|
||||||
'public-users': PublicUserAuthOperations;
|
'public-users': PublicUserAuthOperations;
|
||||||
};
|
};
|
||||||
|
blocks: {};
|
||||||
collections: {
|
collections: {
|
||||||
users: User;
|
users: User;
|
||||||
'public-users': PublicUser;
|
'public-users': PublicUser;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
exactText,
|
exactText,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
openNav,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
} from '../../../helpers.js'
|
} from '../../../helpers.js'
|
||||||
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
|
||||||
@@ -46,6 +45,7 @@ const description = 'Description'
|
|||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
|
|
||||||
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
||||||
|
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
exactText,
|
exactText,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
openNav,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
saveDocHotkeyAndAssert,
|
saveDocHotkeyAndAssert,
|
||||||
// throttleTest,
|
// throttleTest,
|
||||||
@@ -51,6 +50,7 @@ let payload: PayloadTestSDK<Config>
|
|||||||
|
|
||||||
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
||||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||||
|
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@@ -406,6 +406,15 @@ describe('General', () => {
|
|||||||
await expect(link).toBeHidden()
|
await expect(link).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should disable active nav item', async () => {
|
||||||
|
await page.goto(postsUrl.list)
|
||||||
|
await openNav(page)
|
||||||
|
const activeItem = page.locator('.nav .nav__link.active')
|
||||||
|
await expect(activeItem).toBeVisible()
|
||||||
|
const tagName = await activeItem.evaluate((el) => el.tagName.toLowerCase())
|
||||||
|
expect(tagName).toBe('div')
|
||||||
|
})
|
||||||
|
|
||||||
test('breadcrumbs — should navigate from list to dashboard', async () => {
|
test('breadcrumbs — should navigate from list to dashboard', async () => {
|
||||||
await page.goto(postsUrl.list)
|
await page.goto(postsUrl.list)
|
||||||
await page.locator(`.step-nav a[href="${adminRoutes.routes.admin}"]`).click()
|
await page.locator(`.step-nav a[href="${adminRoutes.routes.admin}"]`).click()
|
||||||
|
|||||||
@@ -255,18 +255,6 @@ export async function saveDocAndAssert(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openNav(page: Page): Promise<void> {
|
|
||||||
// check to see if the nav is already open and if not, open it
|
|
||||||
// use the `--nav-open` modifier class to check if the nav is open
|
|
||||||
// this will prevent clicking nav links that are bleeding off the screen
|
|
||||||
if (await page.locator('.template-default.template-default--nav-open').isVisible()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// playwright: get first element with .nav-toggler which is VISIBLE (not hidden), could be 2 elements with .nav-toggler on mobile and desktop but only one is visible
|
|
||||||
await page.locator('.nav-toggler >> visible=true').click()
|
|
||||||
await expect(page.locator('.template-default.template-default--nav-open')).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openDocDrawer(page: Page, selector: string): Promise<void> {
|
export async function openDocDrawer(page: Page, selector: string): Promise<void> {
|
||||||
await wait(500) // wait for parent form state to initialize
|
await wait(500) // wait for parent form state to initialize
|
||||||
await page.locator(selector).click()
|
await page.locator(selector).click()
|
||||||
|
|||||||
21
test/helpers/e2e/toggleNav.ts
Normal file
21
test/helpers/e2e/toggleNav.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
import { wait } from 'payload/shared'
|
||||||
|
|
||||||
|
export async function openNav(page: Page): Promise<void> {
|
||||||
|
// wait for the preferences/media queries to either open or close the nav
|
||||||
|
// TODO: remove this in the future when nav state can be initialized properly
|
||||||
|
await wait(250)
|
||||||
|
|
||||||
|
// check to see if the nav is already open and if not, open it
|
||||||
|
// use the `--nav-open` modifier class to check if the nav is open
|
||||||
|
// this will prevent clicking nav links that are bleeding off the screen
|
||||||
|
if (await page.locator('.template-default.template-default--nav-open').isVisible()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// playwright: get first element with .nav-toggler which is VISIBLE (not hidden), could be 2 elements with .nav-toggler on mobile and desktop but only one is visible
|
||||||
|
await page.locator('.nav-toggler >> visible=true').click()
|
||||||
|
await expect(page.locator('.template-default.template-default--nav-open')).toBeVisible()
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@payload-config": ["./test/fields-relationship/config.ts"],
|
"@payload-config": ["./test/access-control/config.ts"],
|
||||||
"@payloadcms/live-preview": ["./packages/live-preview/src"],
|
"@payloadcms/live-preview": ["./packages/live-preview/src"],
|
||||||
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
||||||
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
|
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user