Skip to content

Commit

Permalink
FI-2539: Add JSON and YAML file input options (#466)
Browse files Browse the repository at this point in the history
* add catch for json parse

* add placeholder upload button

* control serial input

* simplify invalid file input handling

* add error handling for json inputs

* remove extra error

* move upload button to separate component

* fix download file

* restyle upload button

* add copy button to input

---------

Co-authored-by: Alyssa Wang <awang@mitre.org>
  • Loading branch information
AlyssaWang and AlyssaWang authored Apr 23, 2024
1 parent 77f4bfa commit 030468a
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 37 deletions.
75 changes: 53 additions & 22 deletions client/src/components/InputsModal/InputsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -67,7 +70,8 @@ const InputsModal: FC<InputsModalProps> = ({
const [inputsEdited, setInputsEdited] = React.useState<boolean>(false);
const [inputsMap, setInputsMap] = React.useState<Map<string, unknown>>(new Map());
const [inputType, setInputType] = React.useState<string>('Field');
const [baseInput, setBaseInput] = React.useState<string>('');
const [fileType, setFileType] = React.useState<string>('txt');
const [serialInput, setSerialInput] = React.useState<string>('');
const [invalidInput, setInvalidInput] = React.useState<boolean>(false);

const missingRequiredInput = inputs.some((input: TestInput) => {
Expand Down Expand Up @@ -187,13 +191,31 @@ const InputsModal: FC<InputsModalProps> = ({

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<string, unknown>, edited?: boolean) => {
setInputsMap(inputsMap);
setInputsEdited(inputsEdited || edited !== false); // explicit check for false values
Expand Down Expand Up @@ -263,7 +285,7 @@ const InputsModal: FC<InputsModalProps> = ({
} 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);
Expand All @@ -278,6 +300,7 @@ const InputsModal: FC<InputsModalProps> = ({
};

const handleSerialChanges = (serialChanges: string) => {
setSerialInput(serialChanges);
const parsedChanges = parseSerialChanges(serialChanges);
if (parsedChanges !== undefined && parsedChanges.keys !== undefined) {
parsedChanges.forEach((change: TestInput) => {
Expand Down Expand Up @@ -338,24 +361,32 @@ const InputsModal: FC<InputsModalProps> = ({
{inputType === 'Field' ? (
<List>{inputFields}</List>
) : (
<TextField
id={`${inputType}-serial-input`}
minRows={4}
key={baseInput}
error={invalidInput}
defaultValue={baseInput}
label={invalidInput ? `ERROR: INVALID ${inputType}` : inputType}
InputProps={{
classes: {
input: classes.serialInput,
},
}}
color="secondary"
fullWidth
multiline
data-testid="serial-input"
onChange={(e) => handleSerialChanges(e.target.value)}
/>
<Box>
<UploadFileButton onUpload={handleFileUpload} />
<DownloadFileButton fileName={title} fileType={fileType} />
<TextField
id={`${fileType}-serial-input`}
minRows={4}
value={serialInput}
error={invalidInput}
label={invalidInput ? `ERROR: INVALID ${inputType}` : inputType}
InputProps={{
classes: {
input: classes.serialInput,
},
endAdornment: (
<Box sx={{ alignSelf: 'flex-start' }}>
<CopyButton copyText={serialInput} />
</Box>
),
}}
color="secondary"
fullWidth
multiline
data-testid="serial-input"
onChange={(e) => handleSerialChanges(e.target.value)}
/>
</Box>
)}
</main>
</DialogContent>
Expand Down
12 changes: 10 additions & 2 deletions client/src/components/RequestDetailModal/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +18,13 @@ export interface CodeBlockProps {
const CodeBlock: FC<CodeBlockProps> = ({ body, collapsedState = false, headers, title }) => {
const { classes } = useStyles();
const [collapsed, setCollapsed] = React.useState(collapsedState);
const [jsonBody, setJsonBody] = React.useState<string>('');

useEffectOnce(() => {
if (body && body.length > 0) {
setJsonBody(formatBodyIfJSON(body, headers));
}
});

if (body && body.length > 0) {
return (
Expand All @@ -25,7 +33,7 @@ const CodeBlock: FC<CodeBlockProps> = ({ body, collapsedState = false, headers,
subheader={title || 'Code'}
action={
<Box display="flex">
<CopyButton copyText={formatBodyIfJSON(body, headers)} size="small" />
<CopyButton copyText={jsonBody} size="small" />
<CollapseButton
setCollapsed={setCollapsed}
startState={collapsedState}
Expand All @@ -39,7 +47,7 @@ const CodeBlock: FC<CodeBlockProps> = ({ body, collapsedState = false, headers,
<CardContent sx={{ pt: 0 }}>
<pre data-testid="pre">
<code data-testid="code" className={classes.code}>
{formatBodyIfJSON(body, headers)}
{jsonBody}
</code>
</pre>
</CardContent>
Expand Down
28 changes: 16 additions & 12 deletions client/src/components/RequestDetailModal/RequestDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,24 +87,28 @@ const RequestDetailModal: FC<RequestDetailModalProps> = ({
</Typography>
{timestamp && <Typography variant="overline">{timestamp.toLocaleString()}</Typography>}
<HeaderTable headers={request.request_headers || []} />
<CodeBlock
body={request.request_body}
collapsedState={true}
headers={request.request_headers}
title="Request Body"
/>
{request.response_body && (
<CodeBlock
body={request.request_body}
collapsedState={true}
headers={request.request_headers}
title="Request Body"
/>
)}
</Box>
<Box pb={3}>
<Typography variant="h5" component="h3" pb={2}>
Response
</Typography>
<HeaderTable headers={request.response_headers || []} />
<CodeBlock
body={request.response_body}
collapsedState={true}
headers={request.response_headers}
title="Response Body"
/>
{request.response_body && (
<CodeBlock
body={request.response_body}
collapsedState={true}
headers={request.response_headers}
title="Response Body"
/>
)}
</Box>
</DialogContent>
<DialogActions>
Expand Down
8 changes: 7 additions & 1 deletion client/src/components/RequestDetailModal/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { enqueueSnackbar } from 'notistack';
import { RequestHeader } from '~/models/testSuiteModels';

export const formatBodyIfJSON = (
Expand Down Expand Up @@ -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 '';
}
};
41 changes: 41 additions & 0 deletions client/src/components/_common/DownloadFileButton.tsx
Original file line number Diff line number Diff line change
@@ -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<DownloadFileButtonProps> = ({ 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 (
<Button
variant="contained"
component="label"
color="secondary"
aria-label="file-download"
startIcon={<FileDownloadOutlined />}
disableElevation
onClick={downloadFile}
sx={{ mb: 4 }}
>
Download File
</Button>
);
};

export default DownloadFileButton;
63 changes: 63 additions & 0 deletions client/src/components/_common/UploadFileButton.tsx
Original file line number Diff line number Diff line change
@@ -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<UploadFileButtonProps> = ({ onUpload }) => {
const [fileName, setFileName] = React.useState<string>('');

const uploadFile = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<Box display="flex" alignItems="center" sx={{ mb: 2 }}>
<Button
variant="contained"
component="label"
color="secondary"
aria-label="file-upload"
startIcon={<FileUploadOutlined />}
disableElevation
sx={{ flexShrink: 0 }}
>
Upload File
<VisuallyHiddenInput type="file" onChange={(e) => uploadFile(e)} />
</Button>
{fileName && (
<Box display="flex" alignItems="center" sx={{ mx: 2 }}>
<TaskOutlined color="secondary" sx={{ mr: 1 }} />
<Typography variant="subtitle1" sx={{ fontFamily: 'Monospace' }}>
{fileName}
</Typography>
</Box>
)}
</Box>
);
};

export default UploadFileButton;

0 comments on commit 030468a

Please sign in to comment.