diff --git a/client/src/components/InputsModal/InputsModal.tsx b/client/src/components/InputsModal/InputsModal.tsx index a79e170ac..8f7b091ed 100644 --- a/client/src/components/InputsModal/InputsModal.tsx +++ b/client/src/components/InputsModal/InputsModal.tsx @@ -17,6 +17,7 @@ import { } from '@mui/material'; import { Close } from '@mui/icons-material'; import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import YAML from 'js-yaml'; import { useSnackbar } from 'notistack'; import { OAuthCredentials, RunnableType, TestInput } from '~/models/testSuiteModels'; @@ -27,7 +28,9 @@ import InputTextArea from './InputTextArea'; import InputTextField from './InputTextField'; import CustomTooltip from '../_common/CustomTooltip'; import useStyles from './styles'; -import remarkGfm from 'remark-gfm'; +import DownloadFileButton from '../_common/DownloadFileButton'; +import UploadFileButton from '../_common/UploadFileButton'; +import CopyButton from '../_common/CopyButton'; export interface InputsModalProps { runnableType: RunnableType; @@ -67,7 +70,8 @@ const InputsModal: FC = ({ const [inputsEdited, setInputsEdited] = React.useState(false); const [inputsMap, setInputsMap] = React.useState>(new Map()); const [inputType, setInputType] = React.useState('Field'); - const [baseInput, setBaseInput] = React.useState(''); + const [fileType, setFileType] = React.useState('txt'); + const [serialInput, setSerialInput] = React.useState(''); const [invalidInput, setInvalidInput] = React.useState(false); const missingRequiredInput = inputs.some((input: TestInput) => { @@ -187,13 +191,31 @@ const InputsModal: FC = ({ useEffect(() => { setInvalidInput(false); - setBaseInput(serializeMap(inputsMap)); + setSerialInput(serializeMap(inputsMap)); + + // Set download file extensions based on input format + switch (inputType) { + case 'JSON': + setFileType('json'); + break; + case 'YAML': + setFileType('yml'); + break; + default: + setFileType('txt'); + break; + } }, [inputType, open]); const handleInputTypeChange = (e: React.MouseEvent, value: string) => { if (value !== null) setInputType(value); }; + const handleFileUpload = (text: string) => { + handleSerialChanges(text); + setSerialInput(text); + }; + const handleSetInputsMap = (inputsMap: Map, edited?: boolean) => { setInputsMap(inputsMap); setInputsEdited(inputsEdited || edited !== false); // explicit check for false values @@ -263,7 +285,7 @@ const InputsModal: FC = ({ } else { parsed = YAML.load(changes) as TestInput[]; } - // Convert OAuth input values to strings + // Convert OAuth input values to strings; parsed needs to be an array parsed.forEach((input) => { if (input.type === 'oauth_credentials') { input.value = JSON.stringify(input.value); @@ -278,6 +300,7 @@ const InputsModal: FC = ({ }; const handleSerialChanges = (serialChanges: string) => { + setSerialInput(serialChanges); const parsedChanges = parseSerialChanges(serialChanges); if (parsedChanges !== undefined && parsedChanges.keys !== undefined) { parsedChanges.forEach((change: TestInput) => { @@ -338,24 +361,32 @@ const InputsModal: FC = ({ {inputType === 'Field' ? ( {inputFields} ) : ( - handleSerialChanges(e.target.value)} - /> + + + + + + + ), + }} + color="secondary" + fullWidth + multiline + data-testid="serial-input" + onChange={(e) => handleSerialChanges(e.target.value)} + /> + )} diff --git a/client/src/components/RequestDetailModal/CodeBlock.tsx b/client/src/components/RequestDetailModal/CodeBlock.tsx index b0c27751a..973699b17 100644 --- a/client/src/components/RequestDetailModal/CodeBlock.tsx +++ b/client/src/components/RequestDetailModal/CodeBlock.tsx @@ -1,5 +1,6 @@ import React, { FC } from 'react'; import { Box, Card, CardContent, CardHeader, Collapse, Divider } from '@mui/material'; +import { useEffectOnce } from '~/hooks/useEffectOnce'; import { RequestHeader } from '~/models/testSuiteModels'; import CollapseButton from '~/components/_common/CollapseButton'; import CopyButton from '~/components/_common/CopyButton'; @@ -17,6 +18,13 @@ export interface CodeBlockProps { const CodeBlock: FC = ({ body, collapsedState = false, headers, title }) => { const { classes } = useStyles(); const [collapsed, setCollapsed] = React.useState(collapsedState); + const [jsonBody, setJsonBody] = React.useState(''); + + useEffectOnce(() => { + if (body && body.length > 0) { + setJsonBody(formatBodyIfJSON(body, headers)); + } + }); if (body && body.length > 0) { return ( @@ -25,7 +33,7 @@ const CodeBlock: FC = ({ body, collapsedState = false, headers, subheader={title || 'Code'} action={ - + = ({ body, collapsedState = false, headers,
               
-                {formatBodyIfJSON(body, headers)}
+                {jsonBody}
               
             
diff --git a/client/src/components/RequestDetailModal/RequestDetailModal.tsx b/client/src/components/RequestDetailModal/RequestDetailModal.tsx index 651c0e470..aaf719f16 100644 --- a/client/src/components/RequestDetailModal/RequestDetailModal.tsx +++ b/client/src/components/RequestDetailModal/RequestDetailModal.tsx @@ -87,24 +87,28 @@ const RequestDetailModal: FC = ({ {timestamp && {timestamp.toLocaleString()}} - + {request.response_body && ( + + )}
Response - + {request.response_body && ( + + )} diff --git a/client/src/components/RequestDetailModal/helpers/index.ts b/client/src/components/RequestDetailModal/helpers/index.ts index a17adb499..ba05d6483 100644 --- a/client/src/components/RequestDetailModal/helpers/index.ts +++ b/client/src/components/RequestDetailModal/helpers/index.ts @@ -1,3 +1,4 @@ +import { enqueueSnackbar } from 'notistack'; import { RequestHeader } from '~/models/testSuiteModels'; export const formatBodyIfJSON = ( @@ -28,5 +29,10 @@ export const formatBodyIfJSON = ( }; const formatJSON = (json: string): string => { - return JSON.stringify(JSON.parse(json), null, 2); + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch (error) { + enqueueSnackbar('Input is not a JSON file.', { variant: 'error' }); + return ''; + } }; diff --git a/client/src/components/_common/DownloadFileButton.tsx b/client/src/components/_common/DownloadFileButton.tsx new file mode 100644 index 000000000..0155113a7 --- /dev/null +++ b/client/src/components/_common/DownloadFileButton.tsx @@ -0,0 +1,41 @@ +import React, { FC } from 'react'; +import { Button } from '@mui/material'; +import { FileDownloadOutlined } from '@mui/icons-material'; + +export interface DownloadFileButtonProps { + fileName: string; + fileType: string; +} + +const DownloadFileButton: FC = ({ fileName, fileType }) => { + const downloadFile = () => { + const downloadLink = document.createElement('a'); + const file = new Blob( + [(document.getElementById(`${fileType}-serial-input`) as HTMLInputElement)?.value], + { + type: 'text/plain', + } + ); + downloadLink.href = URL.createObjectURL(file); + downloadLink.download = `${fileName}.${fileType}`; + document.body.appendChild(downloadLink); // Required for this to work in FireFox + downloadLink.click(); + }; + + return ( + + ); +}; + +export default DownloadFileButton; diff --git a/client/src/components/_common/UploadFileButton.tsx b/client/src/components/_common/UploadFileButton.tsx new file mode 100644 index 000000000..6098ec5b8 --- /dev/null +++ b/client/src/components/_common/UploadFileButton.tsx @@ -0,0 +1,63 @@ +import React, { ChangeEvent, FC } from 'react'; +import { Box, Button, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { FileUploadOutlined, TaskOutlined } from '@mui/icons-material'; + +export interface UploadFileButtonProps { + onUpload: (text: string) => unknown; +} + +const UploadFileButton: FC = ({ onUpload }) => { + const [fileName, setFileName] = React.useState(''); + + const uploadFile = (e: ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + const file = e.target.files[0]; + setFileName(file.name); + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result?.toString() || ''; + onUpload(text); + }; + reader.readAsText(file); + }; + + const VisuallyHiddenInput = styled('input')({ + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + bottom: 0, + left: 0, + whiteSpace: 'nowrap', + width: 1, + }); + + return ( + + + {fileName && ( + + + + {fileName} + + + )} + + ); +}; + +export default UploadFileButton;