Skip to content

Commit

Permalink
Refactor Link to add more cheap unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
OleksandrNechai committed Sep 12, 2024
1 parent 6ef802b commit 9964639
Show file tree
Hide file tree
Showing 2 changed files with 190 additions and 31 deletions.
91 changes: 60 additions & 31 deletions packages/base/Link/src/Link/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,27 @@ export type Props = BaseProps &
const defaultColor = 'blue'
const defaultVariant = 'anchor'

export const Link: OverridableComponent<Props> = forwardRef<
HTMLAnchorElement,
Props
>(function Link(props, ref) {
type ViewModel = {
className: string
href?: string
target?: string
rel?: string
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void
weight: 'inherit' | 'semibold'
style?: React.CSSProperties
tabIndex?: number
ariaDisabled?: boolean
nativeHTMLAttributes: Omit<Props, keyof BaseProps>
}

/* eslint-disable complexity */
export const calculateViewModel = (props: Props): ViewModel => {
const {
href,
onClick,
children,
className,
color: inputColor = 'blue',
style,
as = 'a',
variant: inputVariant = 'anchor',
tabIndex,
target,
Expand All @@ -103,9 +112,9 @@ export const Link: OverridableComponent<Props> = forwardRef<
visited = false,
noUnderline,
'aria-disabled': ariaDisabled,
...rest
...nativeHTMLAttributes
} = props
const nativeHTMLAttributes = rest

const sanitizedRel = sanitizeRel(rel, target)

// When Link is used as={Link}, TypeScript can't ensure the input to the Link is compatible with its Props type.
Expand All @@ -114,35 +123,55 @@ export const Link: OverridableComponent<Props> = forwardRef<
? inputVariant
: defaultVariant

return {
className: twMerge(
'focus:outline-none hover:underline leading-[inherit]',
COLOR_DISABLED_MAP[color][variant][disabled ? 'disabled' : 'normal'],
disabled ? 'cursor-not-allowed' : '',
noUnderline ? '!no-underline' : '',
visited
? color === 'blue'
? 'visited text-purple-500'
: 'visited text-gray-500'
: '',
className
),
href: disabled ? undefined : href,
target: disabled ? undefined : target,
rel: sanitizedRel,
onClick: disabled ? undefined : onClick,
weight: variant === 'action' ? 'semibold' : 'inherit',
style,
tabIndex,
ariaDisabled: disabled || ariaDisabled,
nativeHTMLAttributes,
}
}

export const Link: OverridableComponent<Props> = forwardRef<
HTMLAnchorElement,
Props
>(function Link(props, ref) {
const viewModel = calculateViewModel(props)

return (
<Typography
{...nativeHTMLAttributes}
{...viewModel.nativeHTMLAttributes}
ref={ref}
as={as}
as={props.as || 'a'}
// @ts-expect-error Typography is incompatible with href prop
href={disabled ? undefined : href}
target={disabled ? undefined : target}
rel={sanitizedRel}
onClick={disabled ? undefined : onClick}
href={viewModel.href}
target={viewModel.target}
rel={viewModel.rel}
onClick={viewModel.onClick}
color='inherit'
weight={variant === 'action' ? 'semibold' : 'inherit'}
className={twMerge(
'focus:outline-none hover:underline leading-[inherit]',
COLOR_DISABLED_MAP[color][variant][disabled ? 'disabled' : 'normal'],
disabled ? 'cursor-not-allowed' : '',
noUnderline ? '!no-underline' : '',
visited
? color === 'blue'
? 'visited text-purple-500'
: 'visited text-gray-500'
: '',
className
)}
style={style}
tabIndex={tabIndex}
aria-disabled={disabled || ariaDisabled}
weight={viewModel.weight}
className={viewModel.className}
style={viewModel.style}
tabIndex={viewModel.tabIndex}
aria-disabled={viewModel.ariaDisabled}
>
{children}
{props.children}
</Typography>
)
})
Expand Down
130 changes: 130 additions & 0 deletions packages/base/Link/src/Link/test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import { render, fireEvent } from '@toptal/picasso-test-utils'
import { MemoryRouter, Link as RouterLink } from 'react-router-dom'

import { calculateViewModel } from './Link'
import { Link } from '../Link'

describe('Link', () => {
Expand Down Expand Up @@ -99,3 +100,132 @@ describe('Link', () => {
expect(getByTestId('foo')).not.toHaveAttribute('href')
})
})

describe('calculateViewModel', () => {
it('should apply default values when no props are provided', () => {
const props = {}
const result = calculateViewModel(props)

expect(result.className).toContain('text-blue-500')
expect(result.href).toBeUndefined()
expect(result.target).toBeUndefined()
expect(result.rel).toBeUndefined()
expect(result.onClick).toBeUndefined()
expect(result.weight).toBe('inherit')
expect(result.ariaDisabled).toBeUndefined()
})

it('should apply correct href, target, rel and onClick when provided', () => {
const props = {
href: 'https://example.com',
target: '_blank',
rel: 'nofollow',
onClick: jest.fn(),
}
const result = calculateViewModel(props)

expect(result.href).toBe('https://example.com')
expect(result.target).toBe('_blank')
expect(result.rel).toBe('nofollow noopener')
expect(result.onClick).toBe(props.onClick)
})

it('should sanitize rel for target="_blank"', () => {
const props = {
target: '_blank',
rel: 'nofollow',
}
const result = calculateViewModel(props)

expect(result.rel).toBe('nofollow noopener')
})

it('should apply disabled behavior', () => {
const props = {
disabled: true,
href: 'https://example.com',
target: '_blank',
onClick: jest.fn(),
}
const result = calculateViewModel(props)

expect(result.className).toContain('cursor-not-allowed')
expect(result.href).toBeUndefined()
expect(result.target).toBeUndefined()
expect(result.onClick).toBeUndefined()
expect(result.ariaDisabled).toBe(true)
})

it('should apply visited class when visited is true', () => {
const props = {
visited: true,
color: 'blue',
}
const result = calculateViewModel(props)

expect(result.className).toContain('visited text-purple-500')
})

it('should handle noUnderline properly', () => {
const props = {
noUnderline: true,
color: 'blue',
}
const result = calculateViewModel(props)

expect(result.className).toContain('!no-underline')
})

it('should apply correct weight based on variant', () => {
const actionProps = {
variant: 'action' as const,
}
const anchorProps = {
variant: 'anchor' as const,
}

const actionResult = calculateViewModel(actionProps)
const anchorResult = calculateViewModel(anchorProps)

expect(actionResult.weight).toBe('semibold')
expect(anchorResult.weight).toBe('inherit')
})

it('should use default values if an unsupported color or variant is provided', () => {
const props = {
color: 'unsupportedColor',
variant: 'unsupportedVariant',
}
const result = calculateViewModel(props)

expect(result.className).toContain('text-blue-500')
expect(result.weight).toBe('inherit')
})

it('should apply custom className if provided', () => {
const props = {
className: 'custom-class',
}
const result = calculateViewModel(props)

expect(result.className).toContain('custom-class')
})

it('should apply tabIndex when provided', () => {
const props = {
tabIndex: 0,
}
const result = calculateViewModel(props)

expect(result.tabIndex).toBe(0)
})

it('should include additional native HTML attributes', () => {
const props = {
'data-test-id': 'link-element',
}
const result = calculateViewModel(props)

expect(result.nativeHTMLAttributes['data-test-id']).toBe('link-element')
})
})

0 comments on commit 9964639

Please sign in to comment.