diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f26373..9b66a9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Support for externalised attachments ([#353](https://github.com/cucumber/react-components/pull/353)) ## [22.1.0] - 2024-03-15 ### Added diff --git a/package-lock.json b/package-lock.json index 6fc7988b..1da5bdf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "highlight-words": "1.2.2", "mime": "^3.0.0", "react-accessible-accordion": "5.0.0", + "react-error-boundary": "^4.0.13", "react-markdown": "6.0.3", "rehype-raw": "5.1.0", "rehype-sanitize": "4.0.0", @@ -1794,7 +1795,6 @@ "version": "7.20.1", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.1.tgz", "integrity": "sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.10" }, @@ -10194,6 +10194,17 @@ "react": "^18.2.0" } }, + "node_modules/react-error-boundary": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz", + "integrity": "sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-frame-component": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/react-frame-component/-/react-frame-component-5.2.3.tgz", @@ -10365,8 +10376,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.10", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", - "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==", - "dev": true + "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==" }, "node_modules/regenerator-transform": { "version": "0.15.0", diff --git a/package.json b/package.json index 7324d7b6..f3435b1b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "highlight-words": "1.2.2", "mime": "^3.0.0", "react-accessible-accordion": "5.0.0", + "react-error-boundary": "^4.0.13", "react-markdown": "6.0.3", "rehype-raw": "5.1.0", "rehype-sanitize": "4.0.0", diff --git a/src/components/CucumberReact.tsx b/src/components/CucumberReact.tsx index 9c61942d..96bb49ad 100644 --- a/src/components/CucumberReact.tsx +++ b/src/components/CucumberReact.tsx @@ -11,7 +11,7 @@ interface IProps { export const CucumberReact: FunctionComponent> = ({ children, - theme = 'light', + theme = 'auto', customRendering = {}, className, }) => { diff --git a/src/components/customise/customRendering.tsx b/src/components/customise/customRendering.tsx index 4af536b9..4a1a4c44 100644 --- a/src/components/customise/customRendering.tsx +++ b/src/components/customise/customRendering.tsx @@ -31,7 +31,7 @@ export interface AttachmentProps { attachment: messages.Attachment } -export type AttachmentClasses = Styles<'text' | 'icon' | 'image'> +export type AttachmentClasses = Styles<'text' | 'log' | 'icon' | 'image'> export interface BackgroundProps { background: messages.Background diff --git a/src/components/gherkin/Attachment.tsx b/src/components/gherkin/Attachment.tsx deleted file mode 100644 index ed5229c2..00000000 --- a/src/components/gherkin/Attachment.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import * as messages from '@cucumber/messages' -import { AttachmentContentEncoding } from '@cucumber/messages' -import { faPaperclip } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -// @ts-ignore -import Convert from 'ansi-to-html' -import React, { FC, useCallback, useEffect, useState } from 'react' - -import { NavigationButton } from '../app/NavigationButton.js' -import { - AttachmentClasses, - AttachmentProps, - DefaultComponent, - useCustomRendering, -} from '../customise/index.js' -import defaultStyles from './Attachment.module.scss' -import { attachmentFilename } from './attachmentFilename.js' -import { ErrorMessage } from './ErrorMessage.js' - -export const DefaultRenderer: DefaultComponent = ({ - attachment, - styles, -}) => { - if (attachment.mediaType.match(/^image\//)) { - return image(attachment, styles) - } else if (attachment.mediaType.match(/^video\//)) { - return video(attachment) - } else if (attachment.mediaType == 'text/x.cucumber.log+plain') { - return text(attachment, prettyANSI, true, styles) - } else if (attachment.mediaType.match(/^text\//)) { - return text(attachment, (s) => s, false, styles) - } else if (attachment.mediaType.match(/^application\/json/)) { - return text(attachment, prettyJSON, false, styles) - } else { - return - } -} - -export const Attachment: React.FunctionComponent = (props) => { - const ResolvedRenderer = useCustomRendering( - 'Attachment', - defaultStyles, - DefaultRenderer - ) - return -} - -const Unknown: FC = ({ attachment }) => { - const [downloadUrl, setDownloadUrl] = useState() - useEffect(() => () => cleanupDownloadUrl(downloadUrl), [downloadUrl]) - const filename = attachmentFilename(attachment) - const onClick = useCallback(() => { - let href - if (downloadUrl) { - href = downloadUrl - } else { - const createdUrl = createDownloadUrl(attachment) - setDownloadUrl(createdUrl) - href = createdUrl - } - - const anchor = document.createElement('a') - anchor.href = href - anchor.download = filename - anchor.click() - }, [attachment, filename, downloadUrl]) - return ( - - - Download {filename} - - ) -} - -function createDownloadUrl(attachment: messages.Attachment) { - console.debug('Creating download url') - const body = - attachment.contentEncoding === AttachmentContentEncoding.BASE64 - ? base64Decode(attachment.body) - : attachment.body - const bytes = Uint8Array.from(body, (m) => m.codePointAt(0) as number) - const file = new File([bytes], 'attachment', { - type: attachment.mediaType, - }) - return URL.createObjectURL(file) -} - -function cleanupDownloadUrl(url?: string) { - if (url) { - console.debug('Revoking download url') - URL.revokeObjectURL(url) - } -} - -function image(attachment: messages.Attachment, classes: AttachmentClasses) { - if (attachment.contentEncoding !== 'BASE64') { - return ( - - ) - } - - const attachmentTitle = attachment.fileName ?? 'Attached Image (' + attachment.mediaType + ')' - - return ( -
- {attachmentTitle} - Embedded Image -
- ) -} - -function video(attachment: messages.Attachment) { - const attachmentTitle = attachment.fileName ?? 'Attached Video (' + attachment.mediaType + ')' - - if (attachment.contentEncoding !== 'BASE64') { - return ( - - ) - } - return ( -
- {attachmentTitle} - -
- ) -} - -function base64Decode(body: string) { - return atob(body) -} - -function text( - attachment: messages.Attachment, - prettify: (body: string) => string, - dangerouslySetInnerHTML: boolean, - classes: AttachmentClasses -) { - const body = - attachment.contentEncoding === 'IDENTITY' ? attachment.body : base64Decode(attachment.body) - - const attachmentTitle = attachment.fileName ?? 'Attached Text (' + attachment.mediaType + ')' - - if (dangerouslySetInnerHTML) { - return ( -
- {attachmentTitle} -
-          
-        
-
- ) - } - return ( -
- {attachmentTitle} -
-        {prettify(body)}
-      
-
- ) -} - -function prettyJSON(s: string) { - try { - return JSON.stringify(JSON.parse(s), null, 2) - } catch (ignore) { - return s - } -} - -function prettyANSI(s: string) { - return new Convert().toHtml(s) -} diff --git a/src/components/gherkin/GherkinStep.tsx b/src/components/gherkin/GherkinStep.tsx index 20da6fe1..894d76e3 100644 --- a/src/components/gherkin/GherkinStep.tsx +++ b/src/components/gherkin/GherkinStep.tsx @@ -14,7 +14,7 @@ import GherkinQueryContext from '../../GherkinQueryContext.js' import { HighLight } from '../app/HighLight.js' import { DefaultComponent, GherkinStepProps, useCustomRendering } from '../customise/index.js' import { TestStepResultDetails } from '../results/index.js' -import { Attachment } from './Attachment.js' +import { Attachment } from './attachment/index.js' import { DataTable as DataTableComponent } from './DataTable.js' import { DocString as DocStringComponent } from './DocString.js' import { Keyword } from './Keyword.js' diff --git a/src/components/gherkin/HookStep.tsx b/src/components/gherkin/HookStep.tsx index 66e08009..3d522890 100644 --- a/src/components/gherkin/HookStep.tsx +++ b/src/components/gherkin/HookStep.tsx @@ -5,7 +5,7 @@ import React from 'react' import CucumberQueryContext from '../../CucumberQueryContext.js' import { HookStepProps, useCustomRendering } from '../customise/index.js' import { TestStepResultDetails } from '../results/index.js' -import { Attachment } from './Attachment.js' +import { Attachment } from './attachment/index.js' import { StepItem } from './StepItem.js' import { Title } from './Title.js' diff --git a/src/components/gherkin/Attachment.module.scss b/src/components/gherkin/attachment/Attachment.module.scss similarity index 54% rename from src/components/gherkin/Attachment.module.scss rename to src/components/gherkin/attachment/Attachment.module.scss index 7dfe1eff..8310ea6f 100644 --- a/src/components/gherkin/Attachment.module.scss +++ b/src/components/gherkin/attachment/Attachment.module.scss @@ -1,6 +1,7 @@ -@import '../../styles/theming'; +@import '../../../styles/theming'; .text { + position: relative; white-space: pre-wrap; font-family: $monoFamily; font-size: 0.875em; @@ -12,6 +13,21 @@ color: $codeTextColor; } +.log { + padding-left: 3.25em; + + &::before { + content: 'Log'; + position: absolute; + top: 0.666em; + left: 0.75em; + text-transform: uppercase; + font-weight: bold; + color: $parameterColor; + opacity: 0.75; + } +} + .icon { margin-right: 0.75em; opacity: 0.333; diff --git a/src/components/gherkin/Attachment.spec.tsx b/src/components/gherkin/attachment/Attachment.spec.tsx similarity index 75% rename from src/components/gherkin/Attachment.spec.tsx rename to src/components/gherkin/attachment/Attachment.spec.tsx index 4f9082b9..41fce80e 100644 --- a/src/components/gherkin/Attachment.spec.tsx +++ b/src/components/gherkin/attachment/Attachment.spec.tsx @@ -1,8 +1,9 @@ import * as messages from '@cucumber/messages' +import { AttachmentContentEncoding } from '@cucumber/messages' import { expect } from 'chai' import React from 'react' -import { render, screen } from '../../../test-utils/index.js' +import { render, screen } from '../../../../test-utils/index.js' import { Attachment } from './Attachment.js' describe('', () => { @@ -18,6 +19,18 @@ describe('', () => { expect(screen.getByRole('button', { name: 'Download document.pdf' })).to.be.visible }) + it('renders a download button for an unknown externalised attachment', () => { + const attachment: messages.Attachment = { + body: '', + mediaType: 'application/pdf', + contentEncoding: messages.AttachmentContentEncoding.IDENTITY, + fileName: 'document.pdf', + } + render() + + expect(screen.getByRole('button', { name: 'Download document.pdf' })).to.be.visible + }) + it('renders a video', () => { const attachment: messages.Attachment = { mediaType: 'video/mp4', @@ -45,6 +58,20 @@ describe('', () => { expect(video).to.have.attr('src', 'data:video/mp4;base64,fake-base64') }) + it('renders an externalised video', () => { + const attachment: messages.Attachment = { + mediaType: 'video/mp4', + body: '', + contentEncoding: messages.AttachmentContentEncoding.IDENTITY, + url: './path-to-video.mp4', + } + const { container } = render() + const summary = container.querySelector('details summary') + const video = container.querySelector('video source') + expect(summary).to.have.text('Attached Video (video/mp4)') + expect(video).to.have.attr('src', './path-to-video.mp4') + }) + it('renders an image', () => { const attachment: messages.Attachment = { mediaType: 'image/png', @@ -72,6 +99,20 @@ describe('', () => { expect(img).to.have.attr('src', 'data:image/png;base64,fake-base64') }) + it('renders an externalised image ', () => { + const attachment: messages.Attachment = { + mediaType: 'image/png', + body: '', + contentEncoding: AttachmentContentEncoding.IDENTITY, + url: './path-to-image.png', + } + const { container } = render() + const summary = container.querySelector('details summary') + const img = container.querySelector('img') + expect(summary).to.have.text('Attached Image (image/png)') + expect(img).to.have.attr('src', './path-to-image.png') + }) + it('renders base64 encoded plaintext', () => { const attachment: messages.Attachment = { mediaType: 'text/plain', @@ -106,9 +147,7 @@ describe('', () => { contentEncoding: messages.AttachmentContentEncoding.IDENTITY, } const { container } = render() - const summary = container.querySelector('details summary') - const data = container.querySelector('details > pre > span') - expect(summary).to.have.text('Attached Text (text/x.cucumber.log+plain)') + const data = container.querySelector('pre > span') expect(data).to.contain.html( 'blackwhite' ) @@ -122,9 +161,7 @@ describe('', () => { contentEncoding: messages.AttachmentContentEncoding.IDENTITY, } const { container } = render() - const summary = container.querySelector('details summary') - const data = container.querySelector('details > pre > span') - expect(summary).to.have.text('the attachment name') + const data = container.querySelector('pre > span') expect(data).to.contain.html( 'blackwhite' ) diff --git a/src/components/gherkin/attachment/Attachment.stories.tsx b/src/components/gherkin/attachment/Attachment.stories.tsx new file mode 100644 index 00000000..32b719c4 --- /dev/null +++ b/src/components/gherkin/attachment/Attachment.stories.tsx @@ -0,0 +1,78 @@ +import { AttachmentContentEncoding } from '@cucumber/messages' +import { Story } from '@ladle/react' +import React from 'react' + +import { CucumberReact } from '../../CucumberReact.js' +import { AttachmentProps } from '../../customise/index.js' +import { Attachment } from './Attachment.js' +// @ts-expect-error vite static asset import +import externalisedImageUrl from './fixture-image.svg?url' +// @ts-expect-error vite static asset import +import externalisedTextUrl from './fixture-text.json?url' + +export default { + title: 'Gherkin/Attachment', +} + +type TemplateArgs = AttachmentProps + +const Template: Story = ({ attachment }) => { + return ( + + + + ) +} + +export const Log = Template.bind({}) +Log.args = { + attachment: { + mediaType: 'text/x.cucumber.log+plain', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', + }, +} satisfies AttachmentProps + +export const ExternalisedImage = Template.bind({}) +ExternalisedImage.storyName = 'Externalised image' +ExternalisedImage.args = { + attachment: { + mediaType: 'image/svg+xml', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: externalisedImageUrl, + }, +} satisfies AttachmentProps + +export const ExternalisedText = Template.bind({}) +ExternalisedText.storyName = 'Externalised text' +ExternalisedText.args = { + attachment: { + mediaType: 'application/json', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: externalisedTextUrl, + }, +} satisfies AttachmentProps + +export const Externalised404 = Template.bind({}) +Externalised404.storyName = 'Externalised 404' +Externalised404.args = { + attachment: { + mediaType: 'application/json', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: '/this-leads-nowhere.json', + }, +} satisfies AttachmentProps + +export const ExternalisedCors = Template.bind({}) +ExternalisedCors.storyName = 'Externalised CORS error' +ExternalisedCors.args = { + attachment: { + mediaType: 'application/json', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: 'https://cucumber.io', + }, +} satisfies AttachmentProps diff --git a/src/components/gherkin/attachment/Attachment.tsx b/src/components/gherkin/attachment/Attachment.tsx new file mode 100644 index 00000000..e7fe653f --- /dev/null +++ b/src/components/gherkin/attachment/Attachment.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { ErrorBoundary } from 'react-error-boundary' + +import { + AttachmentClasses, + AttachmentProps, + DefaultComponent, + useCustomRendering, +} from '../../customise/index.js' +import { ErrorMessage } from '../ErrorMessage.js' +import defaultStyles from './Attachment.module.scss' +import { Image } from './Image.js' +import { Log } from './Log.js' +import { Text } from './Text.js' +import { Unknown } from './Unknown.js' +import { Video } from './Video.js' + +const DefaultRenderer: DefaultComponent = ({ + attachment, + styles, +}) => { + if (attachment.mediaType.match(/^image\//)) { + return + } else if (attachment.mediaType.match(/^video\//)) { + return