diff --git a/src/components/features/execution-timeline/StepsTimeline.js b/src/components/features/execution-timeline/StepsTimeline.js index ff9b4fb..1067f9e 100644 --- a/src/components/features/execution-timeline/StepsTimeline.js +++ b/src/components/features/execution-timeline/StepsTimeline.js @@ -12,6 +12,7 @@ import InfoRoundIcon from '@rsuite/icons/InfoRound'; import WarningRoundIcon from '@rsuite/icons/WarningRound'; import CheckRoundIcon from '@rsuite/icons/CheckRound'; import RemindRoundIcon from '@rsuite/icons/RemindRound'; +import AttachmentList from '../test-attachments/AttachmentList'; const StepsTimeline = function (props) { const { @@ -19,6 +20,7 @@ const StepsTimeline = function (props) { openModal, screenshots, showScreenshots = true, + attachments, } = props; const getScreenShot = (screenshotId) => { @@ -130,6 +132,13 @@ const StepsTimeline = function (props) {
{renderStepBody(step)} + { attachments ? ( + + ) : null }
{renderScreenshot(step)}
diff --git a/src/components/features/test-attachments/AttachmentList.js b/src/components/features/test-attachments/AttachmentList.js new file mode 100644 index 0000000..42e126a --- /dev/null +++ b/src/components/features/test-attachments/AttachmentList.js @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import AttachmentViewer from './AttachmentViewer'; +import KindIcon from './KindIcon'; +import { formatBytes } from './attachmentFormat'; + +/* + * A row of chips, one per attachment, each opening the attachment in a viewer suited to + * its kind. `ids` are the ids the execution (or step) lists; `byId` is the metadata loaded + * by useExecutionAttachments. Ids with no metadata - a manual-testing image on a manual + * step, or an attachment since deleted - are skipped. + */ +const AttachmentList = function ({ + ids, byId, status, showLabel, +}) { + const intl = useIntl(); + const [open, setOpen] = useState(undefined); + + if (!ids || ids.length === 0) return null; + if (status === 'loading') { + return ( +
+ + + +
+ ); + } + if (status === 'error') { + return ( +
+ + + +
+ ); + } + const attachments = ids.map((id) => byId[id]).filter(Boolean); + if (attachments.length === 0) return null; + + return ( +
+ { showLabel ? ( + + + + ) : null } + { attachments.map((attachment) => ( + + ))} + { open ? ( + setOpen(undefined)} /> + ) : null } +
+ ); +}; + +export default AttachmentList; diff --git a/src/components/features/test-attachments/AttachmentViewer.js b/src/components/features/test-attachments/AttachmentViewer.js new file mode 100644 index 0000000..f1b8816 --- /dev/null +++ b/src/components/features/test-attachments/AttachmentViewer.js @@ -0,0 +1,194 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Modal, Button, ButtonToolbar, Loader, +} from 'rsuite'; +import { MdOutlineFileDownload } from 'react-icons/md'; +import TextViewer from './TextViewer'; +import HarViewer from './HarViewer'; +import KindIcon from './KindIcon'; +import { formatBytes } from './attachmentFormat'; + +// Kinds read as text before rendering; everything else is shown from a blob URL or only +// offered for download. +const TEXT_KINDS = ['log', 'json', 'har', 'html']; +const PREVIEWABLE_KINDS = [...TEXT_KINDS, 'video', 'image']; + +// Hands the browser a blob to save under the attachment's own name. The file endpoint +// needs the session cookie, so a plain link would come back 401 in any deployment where +// the API and the UI are on different origins. +const saveBlob = (blob, name) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name || 'attachment'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); +}; + +const AttachmentBody = function ({ attachment, blob, objectUrl }) { + const [text, setText] = useState(undefined); + + useEffect(() => { + let cancelled = false; + if (blob && TEXT_KINDS.includes(attachment.kind)) { + blob.text().then((value) => { if (!cancelled) setText(value); }); + } + return () => { cancelled = true; }; + }, [blob, attachment.kind]); + + switch (attachment.kind) { + case 'video': + return ( + // eslint-disable-next-line jsx-a11y/media-has-caption +