Skip to content

Commit

Permalink
[Links] Show external-link icon where applicable (#768)
Browse files Browse the repository at this point in the history
Close #765

## Changes
- feat: [Link] Programmatically determine `internal vs external` links
and add icon where appropriate
  - Links to specific iRegs open in new tab
  - Links to specific FIG sections open in new tab
  - `mailto:` links are treated as `internal`
- task: Ensure all instances of `<Link>` use the SBL Link for consistent
styling and functionality
- task: [Link] Refactor to simplify implied usage of RouterLink
- task: [Link] Remove unnecessary usages of the `isRouterLink` prop
- task: Overrides DS Notification styles to ensure icon colors
consistently match link's status-based (visited, hover) styles

---------

Co-authored-by: Tanner Ricks <182143365+tanner-ricks@users.noreply.github.com>
Co-authored-by: S T <shindigira@gmail.com>
  • Loading branch information
3 people authored Oct 28, 2024
1 parent 84b452f commit 267d021
Show file tree
Hide file tree
Showing 13 changed files with 117 additions and 70 deletions.
91 changes: 35 additions & 56 deletions src/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,22 @@
import type { LinkProperties as DesignSystemReactLinkProperties } from 'design-system-react';
import {
Link as DesignSystemReactLink,
ListLink as DesignSystemReactListLink,
LinkText,
ListItem,
} from 'design-system-react';

const getIsLinkExternal = (url: string | undefined): boolean => {
if (url === undefined) {
return false;
}
const externalUriSchemes = ['http', 'mailto:', 'tel:', 'sms:', 'ftp:'];
let isExternal = false;
for (const uriScheme of externalUriSchemes) {
if (url.startsWith(uriScheme)) {
isExternal = true;
}
}
return isExternal;
};

const getIsRouterUsageInferred = (
href: string | undefined,
isRouterLink: boolean | undefined,
): boolean => isRouterLink === undefined && !getIsLinkExternal(href);

const getIsRouterLink = (
href: string | undefined,
isRouterLink: boolean | undefined,
): boolean => {
const isRouterUsageInferred = getIsRouterUsageInferred(href, isRouterLink);
if (isRouterUsageInferred) {
return true;
}
if (isRouterLink === undefined) {
return false;
}
return isRouterLink;
};
import {
IconExternalLink,
isExternalLinkImplied,
isNewTabImplied,
} from './Link.utils';

interface LinkProperties extends DesignSystemReactLinkProperties {
// design system react's Link component correctly allows undefined values without defaultProps
/* eslint-disable react/require-default-props */
href?: string | undefined;
isRouterLink?: boolean | undefined;
isExternalLink?: boolean | undefined;
isNewTab?: boolean | undefined;
target?: string | undefined;

/* eslint-enable react/require-default-props */
Expand All @@ -54,42 +29,46 @@ export function Link({
children,
href,
isRouterLink,
isNewTab,
className,
isExternalLink,
...others
}: LinkProperties): JSX.Element {
const isInternalLink = getIsRouterLink(href, isRouterLink);
const otherProperties: LinkProperties = { ...others };
let icon;

// Open link in new tab
const openInNewTab = isNewTab ?? isNewTabImplied(href);
if (openInNewTab) otherProperties.target = '_blank';

// Treat as an External link
const treatExternal = isExternalLink ?? isExternalLinkImplied(String(href));
if (treatExternal) {
otherProperties.hasIcon = true; // Underline text, not icon
icon = <IconExternalLink />; // Display icon
}

if (!isInternalLink) otherProperties.target = '_blank'; // Open link in new tab
const asInAppLink = isRouterLink ?? (!treatExternal && !openInNewTab);

return (
<DesignSystemReactLink
href={href}
isRouterLink={isInternalLink}
isRouterLink={asInAppLink}
className={className}
{...otherProperties}
>
{children}
<LinkText>{children}</LinkText>
{icon}
</DesignSystemReactLink>
);
}

export function ListLink({
href,
isRouterLink,
children,
...others
}: LinkProperties): JSX.Element {
const isInternalLink = getIsRouterLink(href, isRouterLink);
const otherProperties: LinkProperties = { ...others };

if (!isInternalLink) otherProperties.target = '_blank'; // Open link in new tab

export function ListLink({ children, ...others }: LinkProperties): JSX.Element {
return (
<DesignSystemReactListLink
href={href}
isRouterLink={getIsRouterLink(href, isRouterLink)}
{...others}
>
{children}
</DesignSystemReactListLink>
<ListItem>
<Link {...others} type='list'>
{children}
</Link>
</ListItem>
);
}
51 changes: 51 additions & 0 deletions src/components/Link.utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Icon } from 'design-system-react';
import type { ReactElement } from 'react';

// Link to specific regulation
// Ex: /rules-policy/regulations/1002/109/#a-1-ii
const regsLinkPattern = /\/rules-policy\/regulations\/\d+\/\d+\/#.+/;

// Link to specific FIG subsection
// Ex: /small-business-lending/filing-instructions-guide/2024-guide/#4.4.1
const figLinkPattern = /\/filing-instructions-guide\/\d{4}-guide\/#.+/;

/**
* Programmatically determine if a link is external to the CFPB sphere of websites
*/
export const isExternalLinkImplied = (targetUrl: string): boolean => {
let parsed;

try {
parsed = new URL(targetUrl);
} catch {
return false; // Relative targets will fail parsing (ex. '/home')
}

const externalProtocols = ['http', 'tel:', 'sms:', 'ftp:'];
if (externalProtocols.includes(parsed.protocol)) return true;

const internalProtocols = ['mailto:'];
if (internalProtocols.includes(parsed.protocol)) return false;

// Any subdomain of consumerfinance.gov or the current host
const isInternalDomain = new RegExp(
`([\\S]*\\.)?(consumerfinance\\.gov|${window.location.host})`,
).test(parsed.host);

return !isInternalDomain;
};

// External link icon w/ spacing
export function IconExternalLink(): ReactElement {
return (
<>
{' '}
<Icon name='external-link' className='link-icon-override-color' />
</>
);
}

export function isNewTabImplied(href: string | undefined): boolean {
if (!href) return false;
return regsLinkPattern.test(href) || figLinkPattern.test(href);
}
15 changes: 15 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,18 @@ td:last-child{
td {
background-color: white !important;
}

/* Design System overrides */

/* Alerts - all icons in DS Alerts are colored based on the Alert type */
a .link-icon-override-color .cf-icon-svg{
@apply fill-pacific;
}

a:visited .link-icon-override-color .cf-icon-svg {
@apply fill-teal;
}

a:hover .link-icon-override-color .cf-icon-svg {
@apply !fill-pacificDark;
}
6 changes: 3 additions & 3 deletions src/pages/AuthenticatedLanding/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Divider, Hero, Layout, ListLink } from 'design-system-react';
import './Landing.less';

import AdditionalResources from 'components/AdditionalResources';
import BetaAndLegalNotice from 'components/BetaAndLegalNotice';
import { ListLink } from 'components/Link';
import { Divider, Hero, Layout } from 'design-system-react';
import type { ReactElement } from 'react';
import { LoadingContent } from '../../components/Loading';
import { useAssociatedInstitutions } from '../../utils/useAssociatedInstitutions';
import { FileSbl } from './FileSbl';
import './Landing.less';
import { ReviewInstitutions } from './ReviewInstitutions';

function Landing(): ReactElement | null {
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Error/Error500.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function Error500({
We have encountered an error. Visit the platform homepage for
additional resources or contact our support staff.
</span>
<LinkVisitHomepage isRouterLink={false} />
<LinkVisitHomepage />
<br />
<br />
<span className='contact-us'>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Alert, Link, Paragraph } from 'design-system-react';
import { Link } from 'components/Link';
import { Alert, Paragraph } from 'design-system-react';
import { ValidationInitialFetchFailAlert } from 'pages/Filing/FilingApp/FileSubmission.data';
import { dataValidationLink } from 'utils/common';

Expand Down
4 changes: 1 addition & 3 deletions src/pages/Filing/FilingApp/FilingOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ export default function FilingOverview(): ReactElement {
<div className='mx-auto max-w-[48.125rem]'>
<Head title='File your small business lending data' />
<CrumbTrail>
<Link isRouterLink href='/landing'>
Home
</Link>
<Link href='/landing'>Home</Link>
</CrumbTrail>
<main id='main' className='u-mt30 u-mb60'>
<div className='max-w-[41.875rem]'>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Filing/FilingApp/FilingSubmit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
// @ts-nocheck
import WrapperPageContent from 'WrapperPageContent';
import Links from 'components/CommonLinks';
import { Link } from 'components/Link';
import { LoadingContent } from 'components/Loading';
import {
Alert,
Checkbox,
Grid,
Link,
Paragraph,
TextIntroduction,
} from 'design-system-react';
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Filing/UpdateFinancialProfile/UfpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export default function UFPForm({
Home
</Link>
{lei ? (
<Link isRouterLink href={`/institution/${lei}`} key='view-instition'>
<Link href={`/institution/${lei}`} key='view-instition'>
View your financial institution profile
</Link>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable react/require-default-props */
import Links from 'components/CommonLinks';
import { Link } from 'components/Link';
import SectionIntro from 'components/SectionIntro';
import { Link, WellContainer } from 'design-system-react';
import { WellContainer } from 'design-system-react';
import type { ReactNode } from 'react';
import type {
DomainType as Domain,
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Filing/ViewInstitutionProfile/PageIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function PageIntro(): JSX.Element {
}
callToAction={
<List isLinks>
<ListLink isRouterLink href={`/institution/${lei}/update`}>
<ListLink href={`/institution/${lei}/update`}>
Update your financial institution profile
</ListLink>
</List>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import FormParagraph from 'components/FormParagraph';
import InputErrorMessage from 'components/InputErrorMessage';
import { Checkbox, Link, Paragraph } from 'design-system-react';
import { Link } from 'components/Link';
import { Checkbox, Paragraph } from 'design-system-react';
import type { FieldErrors } from 'react-hook-form';
import { Element } from 'react-scroll';

Expand Down
3 changes: 2 additions & 1 deletion src/pages/ProfileForm/Step1Form/NoDatabaseResultError.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AlertFieldLevel, Link, Paragraph } from 'design-system-react';
import { Link } from 'components/Link';
import { AlertFieldLevel, Paragraph } from 'design-system-react';

function NoDatabaseResultError(): JSX.Element {
return (
Expand Down

0 comments on commit 267d021

Please sign in to comment.