init
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import mockRouter from 'next-router-mock';
|
||||
/* IMPORTANT! To get 'next/router' working with tests, add into "jest.setup.js" file following:
|
||||
---
|
||||
jest.mock('next/router', () => require('next-router-mock'));
|
||||
---
|
||||
*/
|
||||
import AppLink from '.';
|
||||
import { capitalize, randomColor } from '@/utils';
|
||||
|
||||
jest.mock('next/navigation', () => {
|
||||
const result = {
|
||||
...require('next-router-mock'),
|
||||
// useSearchParams: () => jest.fn(),
|
||||
usePathname: () => {
|
||||
const router = mockRouter;
|
||||
return router.asPath;
|
||||
},
|
||||
};
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* AppLink wrapped with Mocked Router
|
||||
*/
|
||||
const ComponentToTest = AppLink;
|
||||
|
||||
/**
|
||||
* Tests for <AppLink/> component
|
||||
*/
|
||||
describe('<AppLink/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const text = 'sample text';
|
||||
const url = 'https://example.com/';
|
||||
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it('supports external link', () => {
|
||||
const text = 'external link';
|
||||
const url = 'https://example.com/';
|
||||
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).toHaveAttribute('target', '_blank'); // Open external links in new Tab by default
|
||||
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
|
||||
const rel = (link as any)?.rel;
|
||||
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
|
||||
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
|
||||
});
|
||||
|
||||
it('supports internal link', () => {
|
||||
const text = 'internal link';
|
||||
const url = '/internal-link';
|
||||
render(<ComponentToTest to={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(link).not.toHaveAttribute('rel');
|
||||
});
|
||||
|
||||
it('supports .openInNewTab property', () => {
|
||||
// External link with openInNewTab={false}
|
||||
let text = 'external link in same tab';
|
||||
let url = 'https://example.com/';
|
||||
render(
|
||||
<ComponentToTest href={url} openInNewTab={false}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(link).not.toHaveAttribute('rel');
|
||||
|
||||
// Internal link with openInNewTab={true}
|
||||
text = 'internal link in new tab';
|
||||
url = '/internal-link-in-new-tab';
|
||||
render(
|
||||
<ComponentToTest to={url} openInNewTab>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).toHaveAttribute('target', '_blank'); // Open links in new Tab
|
||||
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
|
||||
const rel = (link as any)?.rel;
|
||||
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
|
||||
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
|
||||
});
|
||||
|
||||
it('supports .className property', () => {
|
||||
let text = 'internal link with specific class';
|
||||
let url = '/internal-link-with-class';
|
||||
let className = 'someClassName';
|
||||
render(
|
||||
<ComponentToTest to={url} className={className}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(className);
|
||||
});
|
||||
|
||||
it('supports .activeClassName property in pair with .to property', () => {
|
||||
let link;
|
||||
let textActive = 'internal link with activeClassName';
|
||||
let textPassive = 'internal link without activeClassName';
|
||||
let url = '/internal-link';
|
||||
let activeClassName = 'someClassName';
|
||||
|
||||
// router.pathhname doesn't match .to prop
|
||||
mockRouter.push('not-' + url);
|
||||
render(
|
||||
<ComponentToTest to={url} activeClassName={activeClassName}>
|
||||
{textPassive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textPassive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass(activeClassName);
|
||||
|
||||
// router.pathhname matches .to prop
|
||||
mockRouter.push(url);
|
||||
render(
|
||||
<ComponentToTest to={url} activeClassName={activeClassName}>
|
||||
{textActive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textActive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(activeClassName);
|
||||
});
|
||||
|
||||
it('supports .activeClassName property in pair with .href property', () => {
|
||||
let link;
|
||||
let textActive = 'external link with activeClassName';
|
||||
let textPassive = 'external link without activeClassName';
|
||||
let url = '/external-link.com';
|
||||
let activeClassName = 'someClassName';
|
||||
|
||||
// router.pathhname doesn't match .href prop
|
||||
mockRouter.push('not-' + url);
|
||||
render(
|
||||
<ComponentToTest href={url} activeClassName={activeClassName}>
|
||||
{textPassive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textPassive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass(activeClassName);
|
||||
|
||||
// router.pathhname matches .href prop
|
||||
mockRouter.push(url);
|
||||
render(
|
||||
<ComponentToTest href={url} activeClassName={activeClassName}>
|
||||
{textActive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textActive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(activeClassName);
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
// Check several times with random colors
|
||||
for (let i = 1; i < 5; i++) {
|
||||
let text = `link #${i} with .color property`;
|
||||
let url = '/internal-link-with-color';
|
||||
let color = randomColor();
|
||||
render(
|
||||
<ComponentToTest to={url} color={color}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveStyle(`color: ${color}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .underline property', () => {
|
||||
// Enumerate all possible values
|
||||
['hover', 'always', 'none'].forEach((underline) => {
|
||||
let text = `link with .underline == "${underline}"`;
|
||||
let url = '/internal-link-with-underline';
|
||||
render(
|
||||
<ComponentToTest to={url} underline={underline as any}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
underline === 'none'
|
||||
? expect(link).toHaveStyle('text-decoration: none')
|
||||
: expect(link).toHaveStyle('text-decoration: underline');
|
||||
// TODO: make "hover" test with "mouse moving"
|
||||
|
||||
expect(link).toHaveClass(`MuiLink-underline${capitalize(underline)}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports .noLinkStyle property', () => {
|
||||
let text = 'internal link noLinkStyle';
|
||||
let url = '/internal-link-no-style';
|
||||
let noLinkStyle = true;
|
||||
render(
|
||||
<ComponentToTest to={url} noLinkStyle={noLinkStyle}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass('MuiLink-root');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
// See: https://github.com/mui-org/material-ui/blob/6b18675c7e6204b77f4c469e113f62ee8be39178/examples/nextjs-with-typescript/src/Link.tsx
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import { AnchorHTMLAttributes, forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import NextLink, { LinkProps as NextLinkProps } from 'next/link';
|
||||
import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link';
|
||||
import { APP_LINK_COLOR, APP_LINK_UNDERLINE } from '../../config';
|
||||
|
||||
export const EXTERNAL_LINK_PROPS = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for NextLinkComposed component
|
||||
*/
|
||||
interface NextLinkComposedProps
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,
|
||||
Omit<NextLinkProps, 'href' | 'as' | 'onClick' | 'onMouseEnter'> {
|
||||
to: NextLinkProps['href'];
|
||||
linkAs?: NextLinkProps['as'];
|
||||
href?: NextLinkProps['href'];
|
||||
}
|
||||
|
||||
/**
|
||||
* NextJS composed link to use with Material UI
|
||||
* @NextLinkComposed NextLinkComposed
|
||||
*/
|
||||
const NextLinkComposed = forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
|
||||
{ to, linkAs, href, replace, scroll, passHref, shallow, prefetch, ...restOfProps },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<NextLink
|
||||
legacyBehavior={true} // TODO: Remove when MUI become compatible with NextJs 13+
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
>
|
||||
<a ref={ref} {...restOfProps} />
|
||||
</NextLink>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for AppLinkForNext component
|
||||
*/
|
||||
export type AppLinkForNextProps = {
|
||||
activeClassName?: string;
|
||||
as?: NextLinkProps['as'];
|
||||
href?: string | NextLinkProps['href'];
|
||||
noLinkStyle?: boolean;
|
||||
to?: string | NextLinkProps['href'];
|
||||
openInNewTab?: boolean;
|
||||
} & Omit<NextLinkComposedProps, 'to' | 'linkAs' | 'href'> &
|
||||
Omit<MuiLinkProps, 'href'>;
|
||||
|
||||
/**
|
||||
* Material UI link for NextJS
|
||||
* A styled version of the Next.js Link component: https://nextjs.org/docs/#with-link
|
||||
* @component AppLinkForNext
|
||||
* @param {string} [activeClassName] - class name for active link, applied when the router.pathname matches .href or .to props
|
||||
* @param {string} [as] - passed to NextJS Link component in .as prop
|
||||
* @param {string} [className] - class name for <a> tag or NextJS Link component
|
||||
* @param {object|function} children - content to wrap with <a> tag
|
||||
* @param {string} [color] - color of the link
|
||||
* @param {boolean} [noLinkStyle] - when true, link will not have MUI styles
|
||||
* @param {string} [to] - internal link URI
|
||||
* @param {string} [href] - external link URI
|
||||
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
|
||||
* @param {string} [underline] - controls "underline" style of the MUI link: 'hover' | 'always' | 'none'
|
||||
*/
|
||||
const AppLinkForNext = forwardRef<HTMLAnchorElement, AppLinkForNextProps>(function Link(props, ref) {
|
||||
const {
|
||||
activeClassName = 'active', // This class is applied to the Link component when the router.pathname matches the href/to prop
|
||||
as: linkAs,
|
||||
className: classNameProps,
|
||||
href,
|
||||
noLinkStyle,
|
||||
role, // Link don't have roles, so just exclude it from ...restOfProps
|
||||
color = APP_LINK_COLOR,
|
||||
underline = APP_LINK_UNDERLINE,
|
||||
to,
|
||||
sx,
|
||||
openInNewTab = Boolean(href), // Open external links in new Tab by default
|
||||
...restOfProps
|
||||
} = props;
|
||||
const currentPath = usePathname();
|
||||
const destination = to ?? href ?? '';
|
||||
const pathname = typeof destination === 'string' ? destination : destination.pathname;
|
||||
const className = clsx(classNameProps, {
|
||||
[activeClassName]: pathname == currentPath && activeClassName,
|
||||
});
|
||||
|
||||
const isExternal =
|
||||
typeof destination === 'string' && (destination.startsWith('http') || destination.startsWith('mailto:'));
|
||||
|
||||
const propsToRender = {
|
||||
color,
|
||||
underline, // 'hover' | 'always' | 'none'
|
||||
...(openInNewTab && EXTERNAL_LINK_PROPS),
|
||||
...restOfProps,
|
||||
};
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={destination as string} ref={ref as any} {...propsToRender} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={destination as string} ref={ref} sx={sx} {...propsToRender} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
return <NextLinkComposed className={className} ref={ref as any} to={destination} {...propsToRender} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
component={NextLinkComposed}
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
to={destination}
|
||||
sx={sx}
|
||||
{...propsToRender}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppLinkForNext;
|
||||
@@ -0,0 +1,4 @@
|
||||
import AppLink, { AppLinkForNextProps as AppLinkProps } from './AppLinkNextNavigation';
|
||||
|
||||
export type { AppLinkProps };
|
||||
export { AppLink as default, AppLink };
|
||||
Reference in New Issue
Block a user