Merge pull request #622 from Its-treason/feature/better-image-preview

feat(#593): Show real response in image preview
This commit is contained in:
Anoop M D 2023-10-19 23:27:22 +05:30 committed by GitHub
commit 922c1ff253
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 186 additions and 126 deletions

View File

@ -0,0 +1,65 @@
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/theme';
const QueryResultPreview = ({
previewTab,
allowedPreviewModes,
data,
dataBuffer,
formattedData,
item,
contentType,
collection,
mode,
disableRunEventListener,
storedTheme
}) => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
// Fail safe, so we don't render anything with an invalid tab
if (!allowedPreviewModes.includes(previewTab)) {
return null;
}
const onRun = () => {
if (disableRunEventListener) {
return;
}
dispatch(sendRequest(item, collection.uid));
};
switch (previewTab) {
case 'preview-web': {
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
return (
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
/>
);
}
case 'preview-image': {
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />;
}
default:
case 'raw': {
return (
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
theme={storedTheme}
onRun={onRun}
value={formattedData}
mode={mode}
readOnly
/>
);
}
}
};
export default QueryResultPreview;

View File

@ -1,24 +1,14 @@
import React from 'react'; import React from 'react';
import get from 'lodash/get';
import CodeEditor from 'components/CodeEditor';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import classnames from 'classnames'; import classnames from 'classnames';
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common'; import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror'; import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import QueryResultPreview from './QueryResultPreview';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { useState } from 'react'; import { useState } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useEffect } from 'react';
const QueryResult = ({ item, collection, data, width, disableRunEventListener, headers, error }) => { import { useTheme } from 'providers/Theme/index';
const { storedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [tab, setTab] = useState('preview');
const dispatch = useDispatch();
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType);
const formatResponse = (data, mode) => { const formatResponse = (data, mode) => {
if (!data) { if (!data) {
@ -31,7 +21,6 @@ const QueryResult = ({ item, collection, data, width, disableRunEventListener, h
if (mode.includes('xml')) { if (mode.includes('xml')) {
let parsed = safeParseXML(data, { collapseContent: true }); let parsed = safeParseXML(data, { collapseContent: true });
if (typeof parsed === 'string') { if (typeof parsed === 'string') {
return parsed; return parsed;
} }
@ -39,99 +28,79 @@ const QueryResult = ({ item, collection, data, width, disableRunEventListener, h
return safeStringifyJSON(parsed, true); return safeStringifyJSON(parsed, true);
} }
if (['text', 'html'].includes(mode)) { if (['text', 'html'].includes(mode) || typeof data === 'string') {
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data);
}
if (mode.includes('image')) {
return item.requestSent.url;
}
// final fallback
if (typeof data === 'string') {
return data; return data;
} }
return safeStringifyJSON(data); return safeStringifyJSON(data);
}; };
const value = formatResponse(data, mode); const QueryResult = ({ item, collection, data, width, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType);
const formattedData = formatResponse(data, mode);
const { storedTheme } = useTheme();
const onRun = () => { const allowedPreviewModes = useMemo(() => {
if (disableRunEventListener) { // Always show raw
return; const allowedPreviewModes = ['raw'];
if (mode.includes('html') && typeof data === 'string') {
allowedPreviewModes.unshift('preview-web');
} else if (mode.includes('image')) {
allowedPreviewModes.unshift('preview-image');
} }
dispatch(sendRequest(item, collection.uid));
};
const getTabClassname = (tabName) => { return allowedPreviewModes;
return classnames(`select-none ${tabName}`, { }, [mode, data, formattedData]);
active: tabName === tab,
'cursor-pointer': tabName !== tab
});
};
const getTabs = () => { const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
if (!mode.includes('html')) { // Ensure the active Tab is always allowed
useEffect(() => {
if (!allowedPreviewModes.includes(previewTab)) {
setPreviewTab(allowedPreviewModes[0]);
}
}, [previewTab, allowedPreviewModes]);
const tabs = useMemo(() => {
if (allowedPreviewModes.length === 1) {
return null; return null;
} }
return ( return allowedPreviewModes.map((previewMode) => (
<> <div
<div className={getTabClassname('raw')} role="tab" onClick={() => setTab('raw')}> className={classnames('select-none capitalize', previewMode === previewTab ? 'active' : 'cursor-pointer')}
Raw role="tab"
onClick={() => setPreviewTab(previewMode)}
key={previewMode}
>
{previewMode.replace(/-(.*)/, ' ')}
</div> </div>
<div className={getTabClassname('preview')} role="tab" onClick={() => setTab('preview')}> ));
Preview }, [allowedPreviewModes, previewTab]);
</div>
</>
);
};
const activeResult = useMemo(() => {
if (
tab === 'preview' &&
mode.includes('html') &&
item.requestSent &&
item.requestSent.url &&
typeof data === 'string'
) {
// Add the Base tag to the head so content loads properly. This also needs the correct CSP settings
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent.url}">`);
return (
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
/>
);
} else if (mode.includes('image')) {
return <img src={item.requestSent.url} alt="image" />;
}
return (
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
theme={storedTheme}
onRun={onRun}
value={value}
mode={mode}
readOnly
/>
);
}, [tab, collection, storedTheme, onRun, value, mode]);
return ( return (
<StyledWrapper className="w-full h-full" style={{ maxWidth: width }}> <StyledWrapper className="w-full h-full" style={{ maxWidth: width }}>
<div className="flex justify-end gap-2 text-xs" role="tablist"> <div className="flex justify-end gap-2 text-xs" role="tablist">
{getTabs()} {tabs}
</div> </div>
{error ? <span className="text-red-500">{error}</span> : activeResult} {error ? (
<span className="text-red-500">{error}</span>
) : (
<QueryResultPreview
previewTab={previewTab}
data={data}
dataBuffer={item.response.dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
mode={mode}
collection={collection}
allowedPreviewModes={allowedPreviewModes}
disableRunEventListener={disableRunEventListener}
storedTheme={storedTheme}
/>
)}
</StyledWrapper> </StyledWrapper>
); );
}; };

View File

@ -43,6 +43,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
data={response.data} data={response.data}
headers={response.headers} headers={response.headers}
error={response.error} error={response.error}
key={item.filename}
/> />
); );
} }

View File

@ -35,6 +35,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
disableRunEventListener={true} disableRunEventListener={true}
data={responseReceived.data} data={responseReceived.data}
headers={responseReceived.headers} headers={responseReceived.headers}
key={item.filename}
/> />
); );
} }

View File

@ -76,14 +76,12 @@ const CreateCollection = ({ onClose }) => {
name="collectionName" name="collectionName"
ref={inputRef} ref={inputRef}
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
onChange = { onChange={(e) => {
(e) => {
formik.handleChange(e); formik.handleChange(e);
if (formik.values.collectionName === formik.values.collectionFolderName) { if (formik.values.collectionName === formik.values.collectionFolderName) {
formik.setFieldValue("collectionFolderName", e.target.value); formik.setFieldValue('collectionFolderName', e.target.value);
}
}
} }
}}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"

View File

@ -8,8 +8,10 @@ export const sendNetworkRequest = async (item, collection, environment, collecti
resolve({ resolve({
state: 'success', state: 'success',
data: response.data, data: response.data,
// Note that the Buffer is encoded as a base64 string, because Buffers / TypedArrays are not allowed in the redux store
dataBuffer: response.dataBuffer,
headers: Object.entries(response.headers), headers: Object.entries(response.headers),
size: getResponseSize(response), size: response.size,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
duration: response.duration duration: response.duration
@ -31,10 +33,6 @@ const sendHttpRequest = async (item, collection, environment, collectionVariable
}); });
}; };
const getResponseSize = (response) => {
return response.headers['content-length'] || Buffer.byteLength(safeStringifyJSON(response.data)) || 0;
};
export const fetchGqlSchema = async (endpoint, environment, request, collection) => { export const fetchGqlSchema = async (endpoint, environment, request, collection) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { ipcRenderer } = window; const { ipcRenderer } = window;

View File

@ -174,6 +174,20 @@ const configureRequest = async (collectionUid, request, envVars, collectionVaria
return axiosInstance; return axiosInstance;
}; };
const parseDataFromResponse = (response) => {
const dataBuffer = Buffer.from(response.data);
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charset = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i.exec(response.headers['Content-Type'] || '');
// Overwrite the original data for backwards compatability
let data = dataBuffer.toString(charset || 'utf-8');
// Try to parse response to JSON, this can quitly fail
try {
data = JSON.parse(response.data);
} catch {}
return { data, dataBuffer };
};
const registerNetworkIpc = (mainWindow) => { const registerNetworkIpc = (mainWindow) => {
// handler for sending http request // handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => { ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => {
@ -336,6 +350,9 @@ const registerNetworkIpc = (mainWindow) => {
// Continue with the rest of the request lifecycle - post response vars, script, assertions, tests // Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
const { data, dataBuffer } = parseDataFromResponse(response);
response.data = data;
// run post-response vars // run post-response vars
const postResponseVars = get(request, 'vars.res', []); const postResponseVars = get(request, 'vars.res', []);
if (postResponseVars?.length) { if (postResponseVars?.length) {
@ -448,6 +465,8 @@ const registerNetworkIpc = (mainWindow) => {
statusText: response.statusText, statusText: response.statusText,
headers: response.headers, headers: response.headers,
data: response.data, data: response.data,
dataBuffer: dataBuffer.toString('base64'),
size: Buffer.byteLength(dataBuffer),
duration: responseTime ?? 0 duration: responseTime ?? 0
}; };
} catch (error) { } catch (error) {
@ -674,6 +693,9 @@ const registerNetworkIpc = (mainWindow) => {
response = await axiosInstance(request); response = await axiosInstance(request);
timeEnd = Date.now(); timeEnd = Date.now();
const { data, dataBuffer } = parseDataFromResponse(response);
response.data = data;
mainWindow.webContents.send('main:run-folder-event', { mainWindow.webContents.send('main:run-folder-event', {
type: 'response-received', type: 'response-received',
responseReceived: { responseReceived: {
@ -681,20 +703,25 @@ const registerNetworkIpc = (mainWindow) => {
statusText: response.statusText, statusText: response.statusText,
headers: Object.entries(response.headers), headers: Object.entries(response.headers),
duration: timeEnd - timeStart, duration: timeEnd - timeStart,
size: response.headers['content-length'] || getSize(response.data), dataBuffer: dataBuffer.toString('base64'),
size: Buffer.byteLength(dataBuffer),
data: response.data data: response.data
}, },
...eventData ...eventData
}); });
} catch (error) { } catch (error) {
if (error?.response) { if (error?.response) {
const { data, dataBuffer } = parseDataFromResponse(error.response);
error.response.data = data;
timeEnd = Date.now(); timeEnd = Date.now();
response = { response = {
status: error.response.status, status: error.response.status,
statusText: error.response.statusText, statusText: error.response.statusText,
headers: Object.entries(error.response.headers), headers: Object.entries(error.response.headers),
duration: timeEnd - timeStart, duration: timeEnd - timeStart,
size: error.response.headers['content-length'] || getSize(error.response.data), dataBuffer: dataBuffer.toString('base64'),
size: Buffer.byteLength(dataBuffer),
data: error.response.data data: error.response.data
}; };

View File

@ -84,7 +84,8 @@ const prepareRequest = (request, collectionRoot) => {
let axiosRequest = { let axiosRequest = {
method: request.method, method: request.method,
url: request.url, url: request.url,
headers: headers headers: headers,
responseType: 'arraybuffer'
}; };
axiosRequest = setAuthHeaders(axiosRequest, request, collectionRoot); axiosRequest = setAuthHeaders(axiosRequest, request, collectionRoot);