This commit is contained in:
hamid
2026-06-16 01:32:43 +03:30
commit 69bbd28bb0
298 changed files with 24728 additions and 0 deletions
@@ -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 };