Skip to content

Commit

Permalink
[DevOverlay] Add Docs Link Button (#74560)
Browse files Browse the repository at this point in the history
This PR added a docs link button. It parses whitelisted URLs from error messages and links to the first one.

![CleanShot 2025-01-07 at 19 51 24](https://github.com/user-attachments/assets/b97a25b7-101d-412e-842f-73da785d7dfe)

Closes NDX-574
Closes NDX-603
  • Loading branch information
devjiwonchoi authored Jan 9, 2025
1 parent 783ec47 commit ff69c50
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react'
import { DocsLinkButton } from './docs-link-button'
import { withShadowPortal } from '../../../storybook/with-shadow-portal'

const meta: Meta<typeof DocsLinkButton> = {
title: 'ErrorOverlayToolbar/DocsLinkButton',
component: DocsLinkButton,
parameters: {
layout: 'centered',
},
decorators: [withShadowPortal],
}

export default meta
type Story = StoryObj<typeof DocsLinkButton>

export const WithDocsUrl: Story = {
args: {
errorMessage: 'Learn more at https://nextjs.org/docs',
},
}

export const WithoutDocsUrl: Story = {
args: {
errorMessage: 'An error occurred without any documentation link',
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { parseUrlFromText } from '../../../helpers/parse-url-from-text'

const docsURLAllowlist = ['https://nextjs.org', 'https://react.dev']

function docsLinkMatcher(text: string): boolean {
return docsURLAllowlist.some((url) => text.startsWith(url))
}

function getDocsURLFromErrorMessage(text: string): string | null {
const urls = parseUrlFromText(text, docsLinkMatcher)

if (urls.length === 0) {
return null
}

return urls[0]
}

export function DocsLinkButton({ errorMessage }: { errorMessage: string }) {
const docsURL = getDocsURLFromErrorMessage(errorMessage)

if (!docsURL) {
return (
<button className="docs-link-button" disabled>
<DocsIcon />
</button>
)
}

return (
<a
title="Related Next.js Docs"
aria-label="Related Next.js Docs"
className="docs-link-button"
href={docsURL}
target="_blank"
rel="noopener noreferrer"
>
<DocsIcon />
</a>
)
}

function DocsIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="error-overlay-toolbar-button-icon"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1H0.75H5C6.2267 1 7.31583 1.58901 8 2.49963C8.68417 1.58901 9.7733 1 11 1H15.25H16V1.75V13V13.75H15.25H10.7426C10.1459 13.75 9.57361 13.9871 9.15165 14.409L8.53033 15.0303H7.46967L6.84835 14.409C6.42639 13.9871 5.8541 13.75 5.25736 13.75H0.75H0V13V1.75V1ZM7.25 4.75C7.25 3.50736 6.24264 2.5 5 2.5H1.5V12.25H5.25736C5.96786 12.25 6.65758 12.4516 7.25 12.8232V4.75ZM8.75 12.8232V4.75C8.75 3.50736 9.75736 2.5 11 2.5H14.5V12.25H10.7426C10.0321 12.25 9.34242 12.4516 8.75 12.8232Z"
fill="currentColor"
/>
</svg>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DebugInfo } from '../../../../../types'
import { NodejsInspectorButton } from './nodejs-inspector-button'
import { noop as css } from '../../../helpers/noop-template'
import { CopyStackTraceButton } from './copy-stack-trace-button'
import { DocsLinkButton } from './docs-link-button'

type ErrorOverlayToolbarProps = {
error: Error | undefined
Expand All @@ -18,6 +19,7 @@ export function ErrorOverlayToolbar({
<NodejsInspectorButton
devtoolsFrontendUrl={debugInfo?.devtoolsFrontendUrl}
/>
<DocsLinkButton errorMessage={error?.message || ''} />
</span>
)
}
Expand All @@ -29,7 +31,8 @@ export const styles = css`
}
.nodejs-inspector-button,
.copy-stack-trace-button {
.copy-stack-trace-button,
.docs-link-button {
display: flex;
justify-content: center;
align-items: center;
Expand All @@ -51,7 +54,7 @@ export const styles = css`
background: var(--color-gray-alpha-100);
}
&:active {
&:not(:disabled):active {
background: var(--color-gray-alpha-200);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { parseUrlFromText } from './parse-url-from-text'

describe('parseUrlFromText', () => {
it('should extract a URL from text', () => {
const text = 'Check out https://nextjs.org for more info'
expect(parseUrlFromText(text)).toEqual(['https://nextjs.org'])
})

it('should extract multiple URLs from text', () => {
const text = 'Visit https://react.dev and https://vercel.com'
expect(parseUrlFromText(text)).toEqual([
'https://react.dev',
'https://vercel.com',
])
})

it('should handle URLs with paths and query parameters', () => {
const text =
'Link: https://nextjs.org/docs/getting-started?query=123#fragment'
expect(parseUrlFromText(text)).toEqual([
'https://nextjs.org/docs/getting-started?query=123#fragment',
])
})

it('should return empty array when no URLs are found', () => {
const text = 'This text contains no URLs'
expect(parseUrlFromText(text)).toEqual([])
})

it('should handle empty string input', () => {
expect(parseUrlFromText('')).toEqual([])
})

it('should filter URLs using matcherFunc', () => {
const text = 'Visit https://react.dev and https://vercel.com'
const matcherFunc = (url: string) => url.includes('vercel')
expect(parseUrlFromText(text, matcherFunc)).toEqual(['https://vercel.com'])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function parseUrlFromText(
text: string,
matcherFunc?: (text: string) => boolean
): string[] {
const linkRegex = /https?:\/\/[^\s/$.?#].[^\s)'"]*/gi
const links = Array.from(text.matchAll(linkRegex), (match) => match[0])

if (matcherFunc) {
return links.filter((link) => matcherFunc(link))
}

return links
}

0 comments on commit ff69c50

Please # to comment.