mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 22:18:33 +00:00
Merge branch 'main' into oauth2_additional_params
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
}
|
||||
9
packages/bruno-app/babel.config.js
Normal file
9
packages/bruno-app/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
['@babel/preset-react', {
|
||||
runtime: 'automatic'
|
||||
}]
|
||||
],
|
||||
plugins: ['babel-plugin-styled-components']
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
"/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/",
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^assets/(.*)$': '<rootDir>/src/assets/$1',
|
||||
'^components/(.*)$': '<rootDir>/src/components/$1',
|
||||
@@ -8,9 +14,17 @@ module.exports = {
|
||||
'^api/(.*)$': '<rootDir>/src/api/$1',
|
||||
'^pageComponents/(.*)$': '<rootDir>/src/pageComponents/$1',
|
||||
'^providers/(.*)$': '<rootDir>/src/providers/$1',
|
||||
'^utils/(.*)$': '<rootDir>/src/utils/$1'
|
||||
'^utils/(.*)$': '<rootDir>/src/utils/$1',
|
||||
'^test-utils/(.*)$': '<rootDir>/src/test-utils/$1'
|
||||
},
|
||||
clearMocks: true,
|
||||
moduleDirectories: ['node_modules', 'src'],
|
||||
testEnvironment: 'node'
|
||||
};
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['@testing-library/jest-dom'],
|
||||
setupFiles: [
|
||||
'<rootDir>/jest.setup.js',
|
||||
],
|
||||
testMatch: [
|
||||
'<rootDir>/src/**/*.spec.[jt]s?(x)'
|
||||
]
|
||||
};
|
||||
11
packages/bruno-app/jest.setup.js
Normal file
11
packages/bruno-app/jest.setup.js
Normal file
@@ -0,0 +1,11 @@
|
||||
jest.mock('nanoid', () => {
|
||||
return {
|
||||
nanoid: () => {}
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('strip-json-comments', () => {
|
||||
return {
|
||||
stripJsonComments: (str) => str
|
||||
};
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"assets/*": ["src/assets/*"],
|
||||
"ui/*": ["src/ui/*"],
|
||||
"components/*": ["src/components/*"],
|
||||
"hooks/*": ["src/hooks/*"],
|
||||
"themes/*": ["src/themes/*"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@usebruno/app",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "rsbuild dev",
|
||||
@@ -11,7 +12,6 @@
|
||||
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@fontsource/inter": "^5.0.15",
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@reduxjs/toolkit": "^1.8.0",
|
||||
@@ -67,34 +67,44 @@
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-inspector": "^6.0.2",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-pdf": "9.1.1",
|
||||
"react-player": "^2.16.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-tooltip": "^5.5.2",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"system": "^2.0.1",
|
||||
"url": "^0.11.3",
|
||||
"xml-formatter": "^3.5.0",
|
||||
"yargs-parser": "^21.1.1",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@rsbuild/core": "^1.1.2",
|
||||
"@rsbuild/plugin-babel": "^1.0.3",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.2.0",
|
||||
"@rsbuild/plugin-react": "^1.0.7",
|
||||
"@rsbuild/plugin-sass": "^1.1.0",
|
||||
"@rsbuild/plugin-styled-components": "1.1.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^3.0.1",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"mini-css-extract-plugin": "^2.4.5",
|
||||
"postcss": "8.4.47",
|
||||
"style-loader": "^3.3.1",
|
||||
|
||||
@@ -20,6 +20,11 @@ export default defineConfig({
|
||||
],
|
||||
source: {
|
||||
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
|
||||
exclude: [
|
||||
'**/test-utils/**',
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*'
|
||||
]
|
||||
},
|
||||
html: {
|
||||
title: 'Bruno'
|
||||
|
||||
40
packages/bruno-app/src/components/BulkEditor/index.js
Normal file
40
packages/bruno-app/src/components/BulkEditor/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { parseBulkKeyValue, serializeBulkKeyValue } from 'utils/common/bulkKeyValueUtils';
|
||||
|
||||
const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
const parsedParams = useMemo(() => serializeBulkKeyValue(params), [params]);
|
||||
|
||||
const handleEdit = (value) => {
|
||||
const parsed = parseBulkKeyValue(value);
|
||||
onChange(parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-[200px]">
|
||||
<CodeEditor
|
||||
mode="text/plain"
|
||||
theme={displayedTheme}
|
||||
font={preferences.codeFont || 'default'}
|
||||
value={parsedParams}
|
||||
onEdit={handleEdit}
|
||||
onSave={onSave}
|
||||
onRun={onRun}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex btn-action justify-between items-center mt-3">
|
||||
<button className="text-link select-none ml-auto" onClick={onToggle}>
|
||||
Key/Value Edit
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkEditor;
|
||||
@@ -8,117 +8,19 @@
|
||||
import React from 'react';
|
||||
import { isEqual, escapeRegExp } from 'lodash';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
const CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
window.JSHINT = JSHINT;
|
||||
|
||||
const TAB_SIZE = 2;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
window.JSHINT = JSHINT;
|
||||
//This should be done dynamically if possible
|
||||
const hintWords = [
|
||||
'res',
|
||||
'res.status',
|
||||
'res.statusText',
|
||||
'res.headers',
|
||||
'res.body',
|
||||
'res.responseTime',
|
||||
'res.getStatus()',
|
||||
'res.getStatusText()',
|
||||
'res.getHeader(name)',
|
||||
'res.getHeaders()',
|
||||
'res.getBody()',
|
||||
'res.setBody(data)',
|
||||
'res.getResponseTime()',
|
||||
'req',
|
||||
'req.url',
|
||||
'req.method',
|
||||
'req.headers',
|
||||
'req.body',
|
||||
'req.timeout',
|
||||
'req.getUrl()',
|
||||
'req.setUrl(url)',
|
||||
'req.getMethod()',
|
||||
'req.getAuthMode()',
|
||||
'req.setMethod(method)',
|
||||
'req.getHeader(name)',
|
||||
'req.getHeaders()',
|
||||
'req.setHeader(name, value)',
|
||||
'req.setHeaders(data)',
|
||||
'req.getBody()',
|
||||
'req.setBody(data)',
|
||||
'req.setMaxRedirects(maxRedirects)',
|
||||
'req.getTimeout()',
|
||||
'req.setTimeout(timeout)',
|
||||
'req.getExecutionMode()',
|
||||
'bru',
|
||||
'bru.cwd()',
|
||||
'bru.getEnvName()',
|
||||
'bru.getProcessEnv(key)',
|
||||
'bru.hasEnvVar(key)',
|
||||
'bru.getEnvVar(key)',
|
||||
'bru.getFolderVar(key)',
|
||||
'bru.getCollectionVar(key)',
|
||||
'bru.setEnvVar(key,value)',
|
||||
'bru.deleteEnvVar(key)',
|
||||
'bru.hasVar(key)',
|
||||
'bru.getVar(key)',
|
||||
'bru.setVar(key,value)',
|
||||
'bru.deleteVar(key)',
|
||||
'bru.deleteAllVars()',
|
||||
'bru.setNextRequest(requestName)',
|
||||
'req.disableParsingResponseJson()',
|
||||
'bru.getRequestVar(key)',
|
||||
'bru.runRequest(requestPathName)',
|
||||
'bru.getAssertionResults()',
|
||||
'bru.getTestResults()',
|
||||
'bru.sleep(ms)',
|
||||
'bru.getGlobalEnvVar(key)',
|
||||
'bru.setGlobalEnvVar(key, value)',
|
||||
'bru.runner',
|
||||
'bru.runner.setNextRequest(requestName)',
|
||||
'bru.runner.skipRequest()',
|
||||
'bru.runner.stopExecution()'
|
||||
];
|
||||
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
|
||||
const cursor = editor.getCursor();
|
||||
const currentLine = editor.getLine(cursor.line);
|
||||
let startBru = cursor.ch;
|
||||
let endBru = startBru;
|
||||
while (endBru < currentLine.length && /[\w.]/.test(currentLine.charAt(endBru))) ++endBru;
|
||||
while (startBru && /[\w.]/.test(currentLine.charAt(startBru - 1))) --startBru;
|
||||
let curWordBru = startBru != endBru && currentLine.slice(startBru, endBru);
|
||||
|
||||
let start = cursor.ch;
|
||||
let end = start;
|
||||
while (end < currentLine.length && /[\w]/.test(currentLine.charAt(end))) ++end;
|
||||
while (start && /[\w]/.test(currentLine.charAt(start - 1))) --start;
|
||||
const jsHinter = CodeMirror.hint.javascript;
|
||||
let result = jsHinter(editor) || { list: [] };
|
||||
result.to = CodeMirror.Pos(cursor.line, end);
|
||||
result.from = CodeMirror.Pos(cursor.line, start);
|
||||
if (curWordBru) {
|
||||
hintWords.forEach((h) => {
|
||||
if (h.includes('.') == curWordBru.includes('.') && h.startsWith(curWordBru)) {
|
||||
result.list.push(curWordBru.includes('.') ? h.split('.')?.at(-1) : h);
|
||||
}
|
||||
});
|
||||
result.list?.sort();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
CodeMirror.commands.autocomplete = (cm, hint, options) => {
|
||||
cm.showHint({ hint, ...options });
|
||||
};
|
||||
}
|
||||
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -138,12 +40,17 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: TAB_SIZE,
|
||||
mode: this.props.mode || 'application/ld+json',
|
||||
brunoVarInfo: {
|
||||
variables
|
||||
},
|
||||
keyMap: 'sublime',
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
@@ -275,30 +182,24 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
return found;
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
this.addOverlay();
|
||||
}
|
||||
if (this.props.mode == 'javascript') {
|
||||
editor.on('keyup', function (cm, event) {
|
||||
const cursor = editor.getCursor();
|
||||
const currentLine = editor.getLine(cursor.line);
|
||||
let start = cursor.ch;
|
||||
let end = start;
|
||||
while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end;
|
||||
while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start;
|
||||
let curWord = start != end && currentLine.slice(start, end);
|
||||
// Qualify if autocomplete will be shown
|
||||
if (
|
||||
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event.key) &&
|
||||
curWord.length > 0 &&
|
||||
!/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) &&
|
||||
/(?<!\d)[a-zA-Z\._]$/.test(curWord)
|
||||
) {
|
||||
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.brunoJS, { completeSingle: false });
|
||||
}
|
||||
});
|
||||
|
||||
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
|
||||
|
||||
// Setup AutoComplete Helper for all modes
|
||||
const autoCompleteOptions = {
|
||||
showHintsFor: this.props.showHintsFor,
|
||||
getAllVariables: getAllVariablesHandler
|
||||
};
|
||||
|
||||
this.brunoAutoCompleteCleanup = setupAutoComplete(
|
||||
editor,
|
||||
autoCompleteOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +240,9 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
|
||||
this._unbindSearchHandler();
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -363,7 +267,7 @@ export default class CodeEditor extends React.Component {
|
||||
let variables = getAllVariables(this.props.collection, this.props.item);
|
||||
this.variables = variables;
|
||||
|
||||
defineCodeMirrorBrunoVariablesMode(variables, mode);
|
||||
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
|
||||
51
packages/bruno-app/src/components/CodeEditor/index.spec.js
Normal file
51
packages/bruno-app/src/components/CodeEditor/index.spec.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { render, act } from '@testing-library/react';
|
||||
import CodeEditor from './index';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
jest.mock('codemirror', () => {
|
||||
const codemirror = require('test-utils/mocks/codemirror');
|
||||
return codemirror;
|
||||
});
|
||||
|
||||
const MOCK_THEME = {
|
||||
codemirror: {
|
||||
bg: "#1e1e1e",
|
||||
border: "#333",
|
||||
},
|
||||
textLink: "#007acc",
|
||||
};
|
||||
|
||||
const setupEditorState = (editor, { value, cursorPosition }) => {
|
||||
editor._currentValue = value;
|
||||
editor.getCursor.mockReturnValue({ line: 0, ch: cursorPosition });
|
||||
editor.getRange.mockImplementation((from, to) => {
|
||||
if (from.line === 0 && from.ch === 0 && to.line === 0 && to.ch === cursorPosition) {
|
||||
return value;
|
||||
}
|
||||
return editor._currentValue.slice(from.ch, to.ch);
|
||||
});
|
||||
|
||||
editor.state = {
|
||||
completionActive: null,
|
||||
}
|
||||
};
|
||||
|
||||
const setupEditorWithRef = () => {
|
||||
const ref = React.createRef();
|
||||
const { rerender } = render(
|
||||
<ThemeProvider theme={MOCK_THEME}>
|
||||
<CodeEditor ref={ref} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
return { ref, rerender };
|
||||
};
|
||||
|
||||
describe('CodeEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("add CodeEditor related tests here", () => {});
|
||||
});
|
||||
@@ -21,12 +21,12 @@ const AwsV4Auth = ({ collection }) => {
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
accessKeyId: accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -38,12 +38,12 @@ const AwsV4Auth = ({ collection }) => {
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -55,12 +55,12 @@ const AwsV4Auth = ({ collection }) => {
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -72,12 +72,12 @@ const AwsV4Auth = ({ collection }) => {
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -89,12 +89,12 @@ const AwsV4Auth = ({ collection }) => {
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -106,12 +106,12 @@ const AwsV4Auth = ({ collection }) => {
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -21,8 +21,8 @@ const BasicAuth = ({ collection }) => {
|
||||
mode: 'basic',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: basicAuth.password
|
||||
username: username || '',
|
||||
password: basicAuth.password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -34,8 +34,8 @@ const BasicAuth = ({ collection }) => {
|
||||
mode: 'basic',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: basicAuth.username,
|
||||
password: password
|
||||
username: basicAuth.username || '',
|
||||
password: password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -21,8 +21,8 @@ const DigestAuth = ({ collection }) => {
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: digestAuth.password
|
||||
username: username || '',
|
||||
password: digestAuth.password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -34,8 +34,8 @@ const DigestAuth = ({ collection }) => {
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: digestAuth.username,
|
||||
password: password
|
||||
username: digestAuth.username || '',
|
||||
password: password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -28,9 +28,9 @@ const NTLMAuth = ({ collection }) => {
|
||||
mode: 'ntlm',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: ntlmAuth.password,
|
||||
domain: ntlmAuth.domain
|
||||
username: username || '',
|
||||
password: ntlmAuth.password || '',
|
||||
domain: ntlmAuth.domain || ''
|
||||
|
||||
}
|
||||
})
|
||||
@@ -43,9 +43,9 @@ const NTLMAuth = ({ collection }) => {
|
||||
mode: 'ntlm',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: ntlmAuth.username,
|
||||
password: password,
|
||||
domain: ntlmAuth.domain
|
||||
username: ntlmAuth.username || '',
|
||||
password: password || '',
|
||||
domain: ntlmAuth.domain || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -57,9 +57,9 @@ const NTLMAuth = ({ collection }) => {
|
||||
mode: 'ntlm',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: ntlmAuth.username,
|
||||
password: ntlmAuth.password,
|
||||
domain: domain
|
||||
username: ntlmAuth.username || '',
|
||||
password: ntlmAuth.password || '',
|
||||
domain: domain || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/in
|
||||
import { useDispatch } from 'react-redux';
|
||||
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
|
||||
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
|
||||
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
|
||||
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
|
||||
|
||||
const GrantTypeComponentMap = ({collection }) => {
|
||||
@@ -29,6 +30,9 @@ const GrantTypeComponentMap = ({collection }) => {
|
||||
case 'client_credentials':
|
||||
return <OAuth2ClientCredentials save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
|
||||
break;
|
||||
case 'implicit':
|
||||
return <OAuth2Implicit save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
|
||||
break;
|
||||
default:
|
||||
return <div>TBD</div>;
|
||||
break;
|
||||
|
||||
@@ -21,8 +21,8 @@ const WsseAuth = ({ collection }) => {
|
||||
mode: 'wsse',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username,
|
||||
password: wsseAuth.password
|
||||
username: username || '',
|
||||
password: wsseAuth.password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -34,8 +34,8 @@ const WsseAuth = ({ collection }) => {
|
||||
mode: 'wsse',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: wsseAuth.username,
|
||||
password
|
||||
username: wsseAuth.username || '',
|
||||
password: password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ const Info = ({ collection }) => {
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-sm">Requests</div>
|
||||
<div className="mt-1 text-sm text-muted font-mono">
|
||||
<div className="mt-1 text-sm text-muted">
|
||||
{
|
||||
isCollectionLoading? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ const Script = ({ collection }) => {
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 mt-6">
|
||||
@@ -66,6 +67,7 @@ const Script = ({ collection }) => {
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const Tests = ({ collection }) => {
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -15,17 +15,9 @@ import Test from './Tests';
|
||||
import Presets from './Presets';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Vars from './Vars/index';
|
||||
import DotIcon from 'components/Icons/Dot';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import Overview from './Overview/index';
|
||||
|
||||
const ContentIndicator = () => {
|
||||
return (
|
||||
<sup className="ml-[.125rem] opacity-80 font-medium">
|
||||
<DotIcon width="10"></DotIcon>
|
||||
</sup>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tab = collection.settingsSelectedTab;
|
||||
@@ -49,7 +41,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
const requestVars = get(collection, 'root.request.vars.req', []);
|
||||
const responseVars = get(collection, 'root.request.vars.res', []);
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
const auth = get(collection, 'root.request.auth', {}).mode;
|
||||
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
|
||||
|
||||
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
|
||||
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
@@ -140,7 +132,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 py-4">
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-scroll">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
@@ -155,29 +147,29 @@ const CollectionSettings = ({ collection }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{auth !== 'none' && <ContentIndicator />}
|
||||
{authMode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <ContentIndicator />}
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
|
||||
Tests
|
||||
{hasTests && <ContentIndicator />}
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && <ContentIndicator />}
|
||||
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
{clientCertConfig.length > 0 && <ContentIndicator />}
|
||||
{clientCertConfig.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
</div>
|
||||
<section className="mt-4 h-full">{getTabPanel(tab)}</section>
|
||||
<section className="mt-4 h-full overflow-scroll">{getTabPanel(tab)}</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
overflow: hidden;
|
||||
|
||||
.debug-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debug-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
.error-count {
|
||||
color: ${(props) => props.theme.console.countColor};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.debug-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.debug-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.debug-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: ${(props) => props.theme.console.emptyColor};
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
padding: 40px 20px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.errors-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.errors-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px 120px;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.errors-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px 120px;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.error-location {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.error-time {
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconBug } from '@tabler/icons';
|
||||
import {
|
||||
setSelectedError,
|
||||
clearDebugErrors
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ErrorRow = ({ error, isSelected, onClick }) => {
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
});
|
||||
};
|
||||
|
||||
const getShortMessage = (message, maxLength = 80) => {
|
||||
if (!message) return 'Unknown error';
|
||||
return message.length > maxLength ? message.substring(0, maxLength) + '...' : message;
|
||||
};
|
||||
|
||||
const getLocation = (error) => {
|
||||
if (error.filename) {
|
||||
const filename = error.filename.split('/').pop(); // Get just the filename
|
||||
if (error.lineno && error.colno) {
|
||||
return `${filename}:${error.lineno}:${error.colno}`;
|
||||
} else if (error.lineno) {
|
||||
return `${filename}:${error.lineno}`;
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`error-row ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="error-message" title={error.message}>
|
||||
{getShortMessage(error.message)}
|
||||
</div>
|
||||
|
||||
<div className="error-location" title={error.filename}>
|
||||
{getLocation(error)}
|
||||
</div>
|
||||
|
||||
<div className="error-time">
|
||||
{formatTime(error.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DebugTab = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { debugErrors, selectedError } = useSelector(state => state.logs);
|
||||
|
||||
const handleErrorClick = (error) => {
|
||||
dispatch(setSelectedError(error));
|
||||
};
|
||||
|
||||
const handleClearErrors = () => {
|
||||
dispatch(clearDebugErrors());
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="debug-content">
|
||||
{debugErrors.length === 0 ? (
|
||||
<div className="debug-empty">
|
||||
<IconBug size={48} strokeWidth={1} />
|
||||
<p>No errors</p>
|
||||
<span>console.error() calls will appear here</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="errors-container">
|
||||
<div className="errors-header">
|
||||
<div>Message</div>
|
||||
<div>Location</div>
|
||||
<div className="text-right">Time</div>
|
||||
</div>
|
||||
|
||||
<div className="errors-list">
|
||||
{debugErrors.map((error, index) => (
|
||||
<ErrorRow
|
||||
key={error.id}
|
||||
error={error}
|
||||
isSelected={selectedError?.id === error.id}
|
||||
onClick={() => handleErrorClick(error)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugTab;
|
||||
@@ -0,0 +1,228 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
border-left: 1px solid ${(props) => props.theme.console.border};
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
.error-time {
|
||||
color: ${(props) => props.theme.console.countColor};
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
}
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.console.checkboxColor};
|
||||
border-bottom-color: ${(props) => props.theme.console.checkboxColor};
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message-full {
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
}
|
||||
|
||||
.file-path {
|
||||
color: ${(props) => props.theme.console.checkboxColor} !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.report-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.report-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 6px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
align-self: flex-start;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.checkboxColor};
|
||||
color: white;
|
||||
border-color: ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.stack-trace-container,
|
||||
.arguments-container {
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stack-trace,
|
||||
.arguments {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
background: transparent;
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconX,
|
||||
IconBug,
|
||||
IconFileText,
|
||||
IconCode,
|
||||
IconStack,
|
||||
IconBrandGithub
|
||||
} from '@tabler/icons';
|
||||
import { clearSelectedError } from 'providers/ReduxStore/slices/logs';
|
||||
import { useApp } from 'providers/App';
|
||||
import platformLib from 'platform';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ErrorInfoTab = ({ error }) => {
|
||||
const { version } = useApp();
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const generateGitHubIssueUrl = () => {
|
||||
const title = `Bug: ${error.message.substring(0, 50)}${error.message.length > 50 ? '...' : ''}`;
|
||||
|
||||
const body = `## Bug Report
|
||||
|
||||
### Error Details
|
||||
- **Message**: ${error.message}
|
||||
- **File**: ${error.filename || 'Unknown'}
|
||||
- **Line**: ${error.lineno || 'Unknown'}:${error.colno || 'Unknown'}
|
||||
- **Timestamp**: ${formatTimestamp(error.timestamp)}
|
||||
|
||||
### Environment
|
||||
- **Bruno Version**: ${version}
|
||||
- **OS**: ${platformLib.os.family} ${platformLib.os.version || ''}
|
||||
- **Browser**: ${platformLib.name} ${platformLib.version || ''}
|
||||
|
||||
### Stack Trace
|
||||
\`\`\`
|
||||
${error.stack || 'No stack trace available'}
|
||||
\`\`\`
|
||||
|
||||
### Arguments
|
||||
\`\`\`
|
||||
${error.args ? error.args.map((arg, index) => {
|
||||
if (arg && typeof arg === 'object' && arg.__type === 'Error') {
|
||||
return `[${index}]: Error: ${arg.message}`;
|
||||
}
|
||||
return `[${index}]: ${typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)}`;
|
||||
}).join('\n') : 'No arguments'}
|
||||
\`\`\`
|
||||
|
||||
### Steps to Reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
|
||||
### Additional Context
|
||||
|
||||
`;
|
||||
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
const encodedBody = encodeURIComponent(body);
|
||||
|
||||
return `https://github.com/usebruno/bruno/issues/new?template=BLANK_ISSUE&title=${encodedTitle}&body=${encodedBody}`;
|
||||
};
|
||||
|
||||
const handleReportIssue = () => {
|
||||
const url = generateGitHubIssueUrl();
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="section">
|
||||
<h4>Error Information</h4>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label>Message:</label>
|
||||
<span className="error-message-full">{error.message || 'No message available'}</span>
|
||||
</div>
|
||||
|
||||
{error.filename && (
|
||||
<div className="info-item">
|
||||
<label>File:</label>
|
||||
<span className="file-path">{error.filename}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.lineno && (
|
||||
<div className="info-item">
|
||||
<label>Line:</label>
|
||||
<span>{error.lineno}{error.colno ? `:${error.colno}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-item">
|
||||
<label>Timestamp:</label>
|
||||
<span>{formatTimestamp(error.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h4>Report Issue</h4>
|
||||
<div className="report-section">
|
||||
<p>Found a bug? Help us improve Bruno by reporting this error on GitHub.</p>
|
||||
<button
|
||||
className="report-button"
|
||||
onClick={handleReportIssue}
|
||||
title="Report this error on GitHub"
|
||||
>
|
||||
<IconBrandGithub size={16} strokeWidth={1.5} />
|
||||
<span>Report Issue on GitHub</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StackTraceTab = ({ error }) => {
|
||||
const formatStackTrace = (stack) => {
|
||||
if (!stack) return 'Stack trace not available';
|
||||
|
||||
return stack
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="section">
|
||||
<h4>Stack Trace</h4>
|
||||
<div className="stack-trace-container">
|
||||
<pre className="stack-trace">
|
||||
{formatStackTrace(error.stack)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArgumentsTab = ({ error }) => {
|
||||
const formatArguments = (args) => {
|
||||
if (!args || args.length === 0) return 'No arguments available';
|
||||
|
||||
try {
|
||||
return args.map((arg, index) => {
|
||||
// Handle special Error object format
|
||||
if (arg && typeof arg === 'object' && arg.__type === 'Error') {
|
||||
return `[${index}]: Error: ${arg.message}\n Name: ${arg.name}\n Stack: ${arg.stack || 'No stack trace'}`;
|
||||
}
|
||||
|
||||
if (typeof arg === 'object' && arg !== null) {
|
||||
return `[${index}]: ${JSON.stringify(arg, null, 2)}`;
|
||||
}
|
||||
|
||||
return `[${index}]: ${String(arg)}`;
|
||||
}).join('\n\n');
|
||||
} catch (e) {
|
||||
return 'Arguments could not be formatted';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="section">
|
||||
<h4>Arguments</h4>
|
||||
<div className="arguments-container">
|
||||
<pre className="arguments">
|
||||
{formatArguments(error.args)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorDetailsPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { selectedError } = useSelector(state => state.logs);
|
||||
const [activeTab, setActiveTab] = useState('info');
|
||||
|
||||
if (!selectedError) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(clearSelectedError());
|
||||
};
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const getTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'info':
|
||||
return <ErrorInfoTab error={selectedError} />;
|
||||
case 'stack':
|
||||
return <StackTraceTab error={selectedError} />;
|
||||
case 'args':
|
||||
return <ArgumentsTab error={selectedError} />;
|
||||
default:
|
||||
return <ErrorInfoTab error={selectedError} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="panel-header">
|
||||
<div className="panel-title">
|
||||
<IconBug size={16} strokeWidth={1.5} />
|
||||
<span>Error Details</span>
|
||||
<span className="error-time">({formatTime(selectedError.timestamp)})</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={handleClose}
|
||||
title="Close details panel"
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'info' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('info')}
|
||||
>
|
||||
<IconFileText size={14} strokeWidth={1.5} />
|
||||
Info
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'stack' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stack')}
|
||||
>
|
||||
<IconStack size={14} strokeWidth={1.5} />
|
||||
Stack
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'args' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('args')}
|
||||
>
|
||||
<IconCode size={14} strokeWidth={1.5} />
|
||||
Args
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-content">
|
||||
{getTabContent()}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorDetailsPanel;
|
||||
@@ -0,0 +1,293 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
overflow: hidden;
|
||||
|
||||
.network-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.network-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
.request-count {
|
||||
color: ${(props) => props.theme.console.countColor};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.network-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.network-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
}
|
||||
|
||||
.network-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: ${(props) => props.theme.console.emptyColor};
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
padding: 40px 20px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.requests-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0; /* Important for proper scrolling */
|
||||
}
|
||||
|
||||
.request-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 45px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.request-domain {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-path {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.request-time {
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.request-duration {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.request-size {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.filter-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
}
|
||||
|
||||
.filter-summary {
|
||||
font-weight: 500;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
max-width: 250px;
|
||||
background: ${(props) => props.theme.console.dropdownBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: ${(props) => props.theme.console.dropdownHeaderBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
.filter-toggle-all {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown-options {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.optionHoverBg};
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin: 0 8px 0 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.filter-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-option-label {
|
||||
color: ${(props) => props.theme.console.optionLabelColor};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.filter-option-count {
|
||||
color: ${(props) => props.theme.console.optionCountColor};
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-left: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconFilter,
|
||||
IconChevronDown,
|
||||
IconNetwork,
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters,
|
||||
setSelectedRequest
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const MethodBadge = ({ method }) => {
|
||||
const getMethodColor = (method) => {
|
||||
switch (method?.toUpperCase()) {
|
||||
case 'GET': return '#10b981';
|
||||
case 'POST': return '#8b5cf6';
|
||||
case 'PUT': return '#f59e0b';
|
||||
case 'DELETE': return '#ef4444';
|
||||
case 'PATCH': return '#06b6d4';
|
||||
case 'HEAD': return '#6b7280';
|
||||
case 'OPTIONS': return '#84cc16';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className="method-badge"
|
||||
style={{ backgroundColor: getMethodColor(method) }}
|
||||
>
|
||||
{method?.toUpperCase() || 'GET'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status, statusCode }) => {
|
||||
const getStatusColor = (code) => {
|
||||
if (code >= 200 && code < 300) return '#10b981';
|
||||
if (code >= 300 && code < 400) return '#f59e0b';
|
||||
if (code >= 400 && code < 500) return '#ef4444';
|
||||
if (code >= 500) return '#dc2626';
|
||||
return '#6b7280';
|
||||
};
|
||||
|
||||
const displayStatus = statusCode || status;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ color: getStatusColor(statusCode) }}
|
||||
>
|
||||
{displayStatus}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const allFiltersEnabled = Object.values(filters).every(f => f);
|
||||
const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="filter-dropdown" ref={dropdownRef}>
|
||||
<button
|
||||
className="filter-dropdown-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title="Filter requests by method"
|
||||
>
|
||||
<IconFilter size={16} strokeWidth={1.5} />
|
||||
<span className="filter-summary">
|
||||
{activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
|
||||
</span>
|
||||
<IconChevronDown size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={`filter-dropdown-menu right`}>
|
||||
<div className="filter-dropdown-header">
|
||||
<span>Filter by Method</span>
|
||||
<button
|
||||
className="filter-toggle-all"
|
||||
onClick={() => onToggleAll(!allFiltersEnabled)}
|
||||
>
|
||||
{allFiltersEnabled ? 'Hide All' : 'Show All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-dropdown-options">
|
||||
{Object.keys(filters).map(method => (
|
||||
<label key={method} className="filter-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters[method]}
|
||||
onChange={(e) => onFilterToggle(method, e.target.checked)}
|
||||
/>
|
||||
<div className="filter-option-content">
|
||||
<MethodBadge method={method} />
|
||||
<span className="filter-option-label">{method}</span>
|
||||
<span className="filter-option-count">({requestCounts[method] || 0})</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
const { data } = request;
|
||||
const { request: req, response: res, timestamp } = data;
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (duration) => {
|
||||
if (!duration) return '-';
|
||||
if (duration < 1000) return `${Math.round(duration)}ms`;
|
||||
return `${(duration / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (!size) return '-';
|
||||
if (size < 1024) return `${size}B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
const getUrl = () => {
|
||||
return req?.url || 'Unknown URL';
|
||||
};
|
||||
|
||||
const getDomain = () => {
|
||||
try {
|
||||
const url = new URL(getUrl());
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return getUrl();
|
||||
}
|
||||
};
|
||||
|
||||
const getPath = () => {
|
||||
try {
|
||||
const url = new URL(getUrl());
|
||||
return url.pathname + url.search;
|
||||
} catch {
|
||||
return getUrl();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`request-row ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="request-method">
|
||||
<MethodBadge method={req?.method} />
|
||||
</div>
|
||||
|
||||
<div className="request-status">
|
||||
<StatusBadge status={res?.status} statusCode={res?.statusCode} />
|
||||
</div>
|
||||
|
||||
<div className="request-domain" title={getDomain()}>
|
||||
{getDomain()}
|
||||
</div>
|
||||
|
||||
<div className="request-path" title={getPath()}>
|
||||
{getPath()}
|
||||
</div>
|
||||
|
||||
<div className="request-time">
|
||||
{formatTime(timestamp)}
|
||||
</div>
|
||||
|
||||
<div className="request-duration">
|
||||
{formatDuration(res?.duration)}
|
||||
</div>
|
||||
|
||||
<div className="request-size">
|
||||
{formatSize(res?.size)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NetworkTab = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { networkFilters, selectedRequest } = useSelector(state => state.logs);
|
||||
const collections = useSelector(state => state.collections.collections);
|
||||
|
||||
const allRequests = useMemo(() => {
|
||||
const requests = [];
|
||||
|
||||
collections.forEach(collection => {
|
||||
if (collection.timeline) {
|
||||
collection.timeline
|
||||
.filter(entry => entry.type === 'request')
|
||||
.forEach(entry => {
|
||||
requests.push({
|
||||
...entry,
|
||||
collectionName: collection.name,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return requests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [collections]);
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
return allRequests.filter(request => {
|
||||
const method = request.data?.request?.method?.toUpperCase() || 'GET';
|
||||
return networkFilters[method];
|
||||
});
|
||||
}, [allRequests, networkFilters]);
|
||||
|
||||
const requestCounts = useMemo(() => {
|
||||
return allRequests.reduce((counts, request) => {
|
||||
const method = request.data?.request?.method?.toUpperCase() || 'GET';
|
||||
counts[method] = (counts[method] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}, [allRequests]);
|
||||
|
||||
const handleFilterToggle = (method, enabled) => {
|
||||
dispatch(updateNetworkFilter({ method, enabled }));
|
||||
};
|
||||
|
||||
const handleToggleAllFilters = (enabled) => {
|
||||
dispatch(toggleAllNetworkFilters(enabled));
|
||||
};
|
||||
|
||||
const handleRequestClick = (request) => {
|
||||
dispatch(setSelectedRequest(request));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="network-content">
|
||||
{filteredRequests.length === 0 ? (
|
||||
<div className="network-empty">
|
||||
<IconNetwork size={48} strokeWidth={1} />
|
||||
<p>No network requests</p>
|
||||
<span>Requests will appear here as you make API calls</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="requests-container">
|
||||
<div className="requests-header">
|
||||
<div>Method</div>
|
||||
<div>Status</div>
|
||||
<div>Domain</div>
|
||||
<div>Path</div>
|
||||
<div>Time</div>
|
||||
<div className="text-right">Duration</div>
|
||||
<div className="text-right">Size</div>
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{filteredRequests.map((request, index) => (
|
||||
<RequestRow
|
||||
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
|
||||
request={request}
|
||||
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
|
||||
onClick={() => handleRequestClick(request)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkTab;
|
||||
@@ -0,0 +1,347 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
border-left: 1px solid ${(props) => props.theme.console.border};
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
.request-time {
|
||||
color: ${(props) => props.theme.console.countColor};
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
}
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.console.checkboxColor};
|
||||
border-bottom-color: ${(props) => props.theme.console.checkboxColor};
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: min-content;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.countColor};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
padding: 4px 8px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
}
|
||||
}
|
||||
|
||||
.headers-table,
|
||||
.timeline-table {
|
||||
overflow: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
max-height: 300px;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
|
||||
thead {
|
||||
background: ${(props) => props.theme.console.dropdownHeaderBg};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-name,
|
||||
.timeline-phase {
|
||||
color: ${(props) => props.theme.console.countColor};
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.header-value,
|
||||
.timeline-message {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: ${(props) => props.theme.console.emptyColor};
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.response-body-container {
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.w-full.h-full.relative.flex {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
background: ${(props) => props.theme.console.headerBg} !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
div[role="tablist"] {
|
||||
background: ${(props) => props.theme.console.dropdownHeaderBg};
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
display: flex !important;
|
||||
gap: 8px !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: center !important;
|
||||
min-height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
|
||||
> div {
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
font-size: 12px !important;
|
||||
padding: 6px 12px !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
white-space: nowrap !important;
|
||||
min-width: auto !important;
|
||||
height: auto !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 500 !important;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
border-color: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.console.checkboxColor};
|
||||
color: white;
|
||||
border-color: ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
}
|
||||
.response-filter {
|
||||
position: absolute !important;
|
||||
bottom: 8px !important;
|
||||
right: 8px !important;
|
||||
left: 8px !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.network-logs-container {
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
|
||||
.network-logs {
|
||||
background: ${(props) => props.theme.console.contentBg} !important;
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
height: 100% !important;
|
||||
max-height: 400px !important;
|
||||
|
||||
pre {
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.4 !important;
|
||||
padding: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconX,
|
||||
IconFileText,
|
||||
IconArrowRight,
|
||||
IconNetwork
|
||||
} from '@tabler/icons';
|
||||
import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
|
||||
import QueryResult from 'components/ResponsePane/QueryResult';
|
||||
import Network from 'components/ResponsePane/Timeline/TimelineItem/Network';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common/index';
|
||||
|
||||
const RequestTab = ({ request, response }) => {
|
||||
const formatHeaders = (headers) => {
|
||||
if (!headers) return [];
|
||||
if (Array.isArray(headers)) return headers;
|
||||
return Object.entries(headers).map(([key, value]) => ({ name: key, value }));
|
||||
};
|
||||
|
||||
const formatBody = (body) => {
|
||||
if (!body) return 'No body';
|
||||
if (typeof body === 'string') return body;
|
||||
return JSON.stringify(body, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="section">
|
||||
<h4>General</h4>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<span className="label">Request URL:</span>
|
||||
<span className="value">{request?.url || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Request Method:</span>
|
||||
<span className="value">{request?.method || 'GET'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h4>Request Headers</h4>
|
||||
{formatHeaders(request?.headers).length > 0 ? (
|
||||
<div className="headers-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formatHeaders(request.headers).map((header, index) => (
|
||||
<tr key={index}>
|
||||
<td className="header-name">{header.name}</td>
|
||||
<td className="header-value">{header.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">No headers</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{request?.body && (
|
||||
<div className="section">
|
||||
<h4>Request Body</h4>
|
||||
<pre className="code-block">{formatBody(request.body)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponseTab = ({ response, request, collection }) => {
|
||||
const formatHeaders = (headers) => {
|
||||
if (!headers) return [];
|
||||
if (Array.isArray(headers)) return headers;
|
||||
return Object.entries(headers).map(([key, value]) => ({ name: key, value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="section">
|
||||
<h4>Response Headers</h4>
|
||||
{formatHeaders(response?.headers).length > 0 ? (
|
||||
<div className="headers-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formatHeaders(response.headers).map((header, index) => (
|
||||
<tr key={index}>
|
||||
<td className="header-name">{header.name}</td>
|
||||
<td className="header-value">{header.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">No headers</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h4>Response Body</h4>
|
||||
<div className="response-body-container">
|
||||
{response?.data || response?.dataBuffer ? (
|
||||
<QueryResult
|
||||
item={{ uid: uuid()}}
|
||||
collection={collection}
|
||||
data={response.data}
|
||||
dataBuffer={response.dataBuffer}
|
||||
headers={response.headers}
|
||||
error={response.error}
|
||||
disableRunEventListener={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state">No response data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NetworkTab = ({ response }) => {
|
||||
const timeline = response?.timeline || [];
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="section">
|
||||
<h4>Network Logs</h4>
|
||||
<div className="network-logs-container">
|
||||
{timeline.length > 0 ? (
|
||||
<Network logs={timeline} />
|
||||
) : (
|
||||
<div className="empty-state">No network logs available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RequestDetailsPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { selectedRequest } = useSelector(state => state.logs);
|
||||
const collections = useSelector(state => state.collections.collections);
|
||||
const [activeTab, setActiveTab] = useState('request');
|
||||
|
||||
if (!selectedRequest) return null;
|
||||
|
||||
const { data } = selectedRequest;
|
||||
const { request, response } = data;
|
||||
|
||||
const collection = collections.find(c => c.uid === selectedRequest.collectionUid);
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(clearSelectedRequest());
|
||||
};
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const getTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'request':
|
||||
return <RequestTab request={request} response={response} />;
|
||||
case 'response':
|
||||
return <ResponseTab response={response} request={request} collection={collection} />;
|
||||
case 'network':
|
||||
return <NetworkTab response={response} />;
|
||||
default:
|
||||
return <RequestTab request={request} response={response} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="panel-header">
|
||||
<div className="panel-title">
|
||||
<IconFileText size={16} strokeWidth={1.5} />
|
||||
<span>Request Details</span>
|
||||
<span className="request-time">({formatTime(selectedRequest.timestamp)})</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={handleClose}
|
||||
title="Close details panel"
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'request' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('request')}
|
||||
>
|
||||
<IconArrowRight size={14} strokeWidth={1.5} />
|
||||
Request
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'response' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('response')}
|
||||
>
|
||||
<IconFileText size={14} strokeWidth={1.5} />
|
||||
Response
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'network' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('network')}
|
||||
>
|
||||
<IconNetwork size={14} strokeWidth={1.5} />
|
||||
Network
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-content">
|
||||
{getTabContent()}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestDetailsPanel;
|
||||
@@ -0,0 +1,520 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.console.bg};
|
||||
border-top: 1px solid ${(props) => props.theme.console.border};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.console-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.console-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.console.checkboxColor};
|
||||
border-bottom-color: ${(props) => props.theme.console.checkboxColor};
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
}
|
||||
}
|
||||
|
||||
.console-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.console-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
.log-count {
|
||||
color: ${(props) => props.theme.console.countColor};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.network-with-details {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.network-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.debug-with-details {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 8px;
|
||||
padding-right: 8px;
|
||||
border-right: 1px solid ${(props) => props.theme.console.border};
|
||||
}
|
||||
|
||||
.action-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
}
|
||||
|
||||
&.close-button:hover {
|
||||
background: #e81123;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
border-color: ${(props) => props.theme.console.border};
|
||||
}
|
||||
|
||||
.filter-summary {
|
||||
font-weight: 500;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
max-width: 250px;
|
||||
background: ${(props) => props.theme.console.dropdownBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
|
||||
&.right {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: ${(props) => props.theme.console.dropdownHeaderBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
.filter-toggle-all {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown-options {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.optionHoverBg};
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin: 0 8px 0 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.filter-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-option-label {
|
||||
color: ${(props) => props.theme.console.optionLabelColor};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.filter-option-count {
|
||||
color: ${(props) => props.theme.console.optionCountColor};
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.console-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: ${(props) => props.theme.console.emptyColor};
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
padding: 40px 20px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 45px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 4px 16px;
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background-color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-left-color: #f14c4c;
|
||||
|
||||
.log-level {
|
||||
background: #f14c4c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
color: #f14c4c;
|
||||
}
|
||||
}
|
||||
|
||||
&.warn {
|
||||
border-left-color: #ffcc02;
|
||||
|
||||
.log-level {
|
||||
background: #ffcc02;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
color: #ffcc02;
|
||||
}
|
||||
}
|
||||
|
||||
&.info {
|
||||
border-left-color: #0078d4;
|
||||
|
||||
.log-level {
|
||||
background: #0078d4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
color: #0078d4;
|
||||
}
|
||||
}
|
||||
|
||||
&.debug {
|
||||
border-left-color: #9b59b6;
|
||||
|
||||
.log-level {
|
||||
background: #9b59b6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
color: #9b59b6;
|
||||
}
|
||||
}
|
||||
|
||||
&.log {
|
||||
border-left-color: #6a6a6a;
|
||||
|
||||
.log-level {
|
||||
background: #6a6a6a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
color: #6a6a6a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
|
||||
.log-object {
|
||||
margin: 4px 0;
|
||||
padding: 8px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
|
||||
.react-json-view {
|
||||
background: transparent !important;
|
||||
|
||||
.object-key-val {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.object-key {
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.object-value {
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
}
|
||||
|
||||
.string-value {
|
||||
color: ${(props) => props.theme.colors?.text?.green || (props.theme.console.messageColor)} !important;
|
||||
}
|
||||
|
||||
.number-value {
|
||||
color: ${(props) => props.theme.colors?.text?.purple || (props.theme.console.messageColor)} !important;
|
||||
}
|
||||
|
||||
.boolean-value {
|
||||
color: ${(props) => props.theme.colors?.text?.yellow || (props.theme.console.messageColor)} !important;
|
||||
}
|
||||
|
||||
.null-value {
|
||||
color: ${(props) => props.theme.colors?.text?.danger || (props.theme.console.messageColor)} !important;
|
||||
}
|
||||
|
||||
.object-size {
|
||||
color: ${(props) => props.theme.console.timestampColor} !important;
|
||||
}
|
||||
|
||||
.brace, .bracket {
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
}
|
||||
|
||||
.collapsed-icon, .expanded-icon {
|
||||
color: ${(props) => props.theme.console.checkboxColor} !important;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
color: ${(props) => props.theme.console.checkboxColor} !important;
|
||||
}
|
||||
|
||||
.click-to-expand, .click-to-collapse {
|
||||
color: ${(props) => props.theme.console.checkboxColor} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
531
packages/bruno-app/src/components/Devtools/Console/index.js
Normal file
531
packages/bruno-app/src/components/Devtools/Console/index.js
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ReactJson from 'react-json-view';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import {
|
||||
IconX,
|
||||
IconTrash,
|
||||
IconFilter,
|
||||
IconAlertTriangle,
|
||||
IconAlertCircle,
|
||||
IconBug,
|
||||
IconCode,
|
||||
IconChevronDown,
|
||||
IconTerminal2,
|
||||
IconNetwork
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
closeConsole,
|
||||
clearLogs,
|
||||
updateFilter,
|
||||
toggleAllFilters,
|
||||
setActiveTab,
|
||||
clearDebugErrors,
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import NetworkTab from './NetworkTab';
|
||||
import RequestDetailsPanel from './RequestDetailsPanel';
|
||||
// import DebugTab from './DebugTab';
|
||||
import ErrorDetailsPanel from './ErrorDetailsPanel';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const LogIcon = ({ type }) => {
|
||||
const iconProps = { size: 16, strokeWidth: 1.5 };
|
||||
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return <IconAlertCircle className="log-icon error" {...iconProps} />;
|
||||
case 'warn':
|
||||
return <IconAlertTriangle className="log-icon warn" {...iconProps} />;
|
||||
case 'info':
|
||||
return <IconAlertTriangle className="log-icon info" {...iconProps} />;
|
||||
// case 'debug':
|
||||
// return <IconBug className="log-icon debug" {...iconProps} />;
|
||||
default:
|
||||
return <IconCode className="log-icon log" {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const LogTimestamp = ({ timestamp }) => {
|
||||
const date = new Date(timestamp);
|
||||
const time = date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
});
|
||||
|
||||
return <span className="log-timestamp">{time}</span>;
|
||||
};
|
||||
|
||||
const LogMessage = ({ message, args }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
const formatMessage = (msg, originalArgs) => {
|
||||
if (originalArgs && originalArgs.length > 0) {
|
||||
return originalArgs.map((arg, index) => {
|
||||
if (typeof arg === 'object' && arg !== null) {
|
||||
return (
|
||||
<div key={index} className="log-object">
|
||||
<ReactJson
|
||||
src={arg}
|
||||
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
iconStyle="triangle"
|
||||
indentWidth={2}
|
||||
collapsed={1}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={false}
|
||||
enableClipboard={false}
|
||||
name={false}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return String(arg);
|
||||
});
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
const formattedMessage = formatMessage(message, args);
|
||||
|
||||
return (
|
||||
<span className="log-message">
|
||||
{Array.isArray(formattedMessage) ? formattedMessage.map((item, index) => (
|
||||
<span key={index}>{item} </span>
|
||||
)) : formattedMessage}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterDropdown = ({ filters, logCounts, onFilterToggle, onToggleAll }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const allFiltersEnabled = Object.values(filters).every(f => f);
|
||||
const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="filter-dropdown" ref={dropdownRef}>
|
||||
<button
|
||||
className="filter-dropdown-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title="Filter logs by type"
|
||||
>
|
||||
<IconFilter size={16} strokeWidth={1.5} />
|
||||
<span className="filter-summary">
|
||||
{activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
|
||||
</span>
|
||||
<IconChevronDown size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={`filter-dropdown-menu right`}>
|
||||
<div className="filter-dropdown-header">
|
||||
<span>Filter by Type</span>
|
||||
<button
|
||||
className="filter-toggle-all"
|
||||
onClick={() => onToggleAll(!allFiltersEnabled)}
|
||||
>
|
||||
{allFiltersEnabled ? 'Hide All' : 'Show All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-dropdown-options">
|
||||
{Object.entries(filters).map(([filterType, enabled]) => (
|
||||
<label key={filterType} className="filter-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => onFilterToggle(filterType, e.target.checked)}
|
||||
/>
|
||||
<div className="filter-option-content">
|
||||
<LogIcon type={filterType} />
|
||||
<span className="filter-option-label">{filterType}</span>
|
||||
<span className="filter-option-count">({logCounts[filterType] || 0})</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const allFiltersEnabled = Object.values(filters).every(f => f);
|
||||
const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
|
||||
|
||||
const getMethodColor = (method) => {
|
||||
switch (method?.toUpperCase()) {
|
||||
case 'GET': return '#10b981';
|
||||
case 'POST': return '#8b5cf6';
|
||||
case 'PUT': return '#f59e0b';
|
||||
case 'DELETE': return '#ef4444';
|
||||
case 'PATCH': return '#06b6d4';
|
||||
case 'HEAD': return '#6b7280';
|
||||
case 'OPTIONS': return '#84cc16';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="filter-dropdown" ref={dropdownRef}>
|
||||
<button
|
||||
className="filter-dropdown-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title="Filter requests by method"
|
||||
>
|
||||
<IconFilter size={16} strokeWidth={1.5} />
|
||||
<span className="filter-summary">
|
||||
{activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
|
||||
</span>
|
||||
<IconChevronDown size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={`filter-dropdown-menu right`}>
|
||||
<div className="filter-dropdown-header">
|
||||
<span>Filter by Method</span>
|
||||
<button
|
||||
className="filter-toggle-all"
|
||||
onClick={() => onToggleAll(!allFiltersEnabled)}
|
||||
>
|
||||
{allFiltersEnabled ? 'Hide All' : 'Show All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-dropdown-options">
|
||||
{Object.entries(filters).map(([method, enabled]) => (
|
||||
<label key={method} className="filter-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => onFilterToggle(method, e.target.checked)}
|
||||
/>
|
||||
<div className="filter-option-content">
|
||||
<span className="method-badge" style={{ backgroundColor: getMethodColor(method) }}>
|
||||
{method}
|
||||
</span>
|
||||
<span className="filter-option-label">{method}</span>
|
||||
<span className="filter-option-count">({requestCounts[method] || 0})</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConsoleTab = ({ logs, filters, logCounts, onFilterToggle, onToggleAll, onClearLogs }) => {
|
||||
const logsEndRef = useRef(null);
|
||||
const prevLogsCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Only scroll when new logs are added, not when switching tabs
|
||||
if (logsEndRef.current && logs.length > prevLogsCountRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'auto' });
|
||||
}
|
||||
prevLogsCountRef.current = logs.length;
|
||||
}, [logs]);
|
||||
|
||||
const filteredLogs = logs.filter(log => filters[log.type]);
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="tab-content-area">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="console-empty">
|
||||
<IconTerminal2 size={48} strokeWidth={1} />
|
||||
<p>No logs to display</p>
|
||||
<span>Logs will appear here as your application runs</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="logs-container">
|
||||
{filteredLogs.map((log) => (
|
||||
<div key={log.id} className={`log-entry ${log.type}`}>
|
||||
<div className="log-meta">
|
||||
<LogTimestamp timestamp={log.timestamp} />
|
||||
<LogIcon type={log.type} />
|
||||
</div>
|
||||
<LogMessage message={log.message} args={log.args} />
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Console = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector(state => state.logs);
|
||||
const collections = useSelector(state => state.collections.collections);
|
||||
const consoleRef = useRef(null);
|
||||
|
||||
const logCounts = logs.reduce((counts, log) => {
|
||||
counts[log.type] = (counts[log.type] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
|
||||
const allRequests = React.useMemo(() => {
|
||||
const requests = [];
|
||||
|
||||
collections.forEach(collection => {
|
||||
if (collection.timeline) {
|
||||
collection.timeline
|
||||
.filter(entry => entry.type === 'request')
|
||||
.forEach(entry => {
|
||||
requests.push({
|
||||
...entry,
|
||||
collectionName: collection.name,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return requests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [collections]);
|
||||
|
||||
const filteredLogs = logs.filter(log => filters[log.type]);
|
||||
const filteredRequests = allRequests.filter(request => {
|
||||
const method = request.data?.request?.method?.toUpperCase() || 'GET';
|
||||
return networkFilters[method];
|
||||
});
|
||||
|
||||
const requestCounts = allRequests.reduce((counts, request) => {
|
||||
const method = request.data?.request?.method?.toUpperCase() || 'GET';
|
||||
counts[method] = (counts[method] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
|
||||
const handleFilterToggle = (filterType, enabled) => {
|
||||
dispatch(updateFilter({ filterType, enabled }));
|
||||
};
|
||||
|
||||
const handleNetworkFilterToggle = (method, enabled) => {
|
||||
dispatch(updateNetworkFilter({ method, enabled }));
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
dispatch(clearLogs());
|
||||
};
|
||||
|
||||
const handleClearDebugErrors = () => {
|
||||
dispatch(clearDebugErrors());
|
||||
};
|
||||
|
||||
const handlecloseConsole = () => {
|
||||
dispatch(closeConsole());
|
||||
};
|
||||
|
||||
const handleToggleAllFilters = (enabled) => {
|
||||
dispatch(toggleAllFilters(enabled));
|
||||
};
|
||||
|
||||
const handleToggleAllNetworkFilters = (enabled) => {
|
||||
dispatch(toggleAllNetworkFilters(enabled));
|
||||
};
|
||||
|
||||
const handleTabChange = (tab) => {
|
||||
dispatch(setActiveTab(tab));
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'console':
|
||||
return (
|
||||
<ConsoleTab
|
||||
logs={logs}
|
||||
filters={filters}
|
||||
logCounts={logCounts}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
onToggleAll={handleToggleAllFilters}
|
||||
onClearLogs={handleClearLogs}
|
||||
/>
|
||||
);
|
||||
case 'network':
|
||||
return <NetworkTab />;
|
||||
// case 'debug':
|
||||
// return <DebugTab />;
|
||||
default:
|
||||
return (
|
||||
<ConsoleTab
|
||||
logs={logs}
|
||||
filters={filters}
|
||||
logCounts={logCounts}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
onToggleAll={handleToggleAllFilters}
|
||||
onClearLogs={handleClearLogs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTabControls = () => {
|
||||
switch (activeTab) {
|
||||
case 'console':
|
||||
return (
|
||||
<div className="tab-controls">
|
||||
<div className="filter-controls">
|
||||
<FilterDropdown
|
||||
filters={filters}
|
||||
logCounts={logCounts}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
onToggleAll={handleToggleAllFilters}
|
||||
/>
|
||||
</div>
|
||||
<div className="action-controls">
|
||||
<button
|
||||
className="control-button"
|
||||
onClick={handleClearLogs}
|
||||
title="Clear all logs"
|
||||
>
|
||||
<IconTrash size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'network':
|
||||
return (
|
||||
<div className="tab-controls">
|
||||
<div className="filter-controls">
|
||||
<NetworkFilterDropdown
|
||||
filters={networkFilters}
|
||||
requestCounts={requestCounts}
|
||||
onFilterToggle={handleNetworkFilterToggle}
|
||||
onToggleAll={handleToggleAllNetworkFilters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// case 'debug':
|
||||
// return (
|
||||
// <div className="tab-controls">
|
||||
// <div className="action-controls">
|
||||
// {debugErrors.length > 0 && (
|
||||
// <button
|
||||
// className="control-button"
|
||||
// onClick={handleClearDebugErrors}
|
||||
// title="Clear all errors"
|
||||
// >
|
||||
// <IconTrash size={16} strokeWidth={1.5} />
|
||||
// </button>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<StyledWrapper ref={consoleRef}>
|
||||
<div
|
||||
className="console-resize-handle"
|
||||
/>
|
||||
|
||||
<div className="console-header">
|
||||
<div className="console-tabs">
|
||||
<button
|
||||
className={`console-tab ${activeTab === 'console' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('console')}
|
||||
>
|
||||
<IconTerminal2 size={16} strokeWidth={1.5} />
|
||||
<span>Console</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`console-tab ${activeTab === 'network' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('network')}
|
||||
>
|
||||
<IconNetwork size={16} strokeWidth={1.5} />
|
||||
<span>Network</span>
|
||||
</button>
|
||||
|
||||
{/* <button
|
||||
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('debug')}
|
||||
>
|
||||
<IconBug size={16} strokeWidth={1.5} />
|
||||
<span>Debug</span>
|
||||
</button> */}
|
||||
</div>
|
||||
|
||||
<div className="console-controls">
|
||||
{renderTabControls()}
|
||||
<button
|
||||
className="control-button close-button"
|
||||
onClick={handlecloseConsole}
|
||||
title="Close console"
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="console-content">
|
||||
{activeTab === 'network' && selectedRequest ? (
|
||||
<div className="network-with-details">
|
||||
<div className="network-main">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
<RequestDetailsPanel />
|
||||
</div>
|
||||
) : activeTab === 'debug' && selectedError ? (
|
||||
<div className="debug-with-details">
|
||||
<div className="debug-main">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
<ErrorDetailsPanel />
|
||||
</div>
|
||||
) : (
|
||||
renderTabContent()
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Console;
|
||||
88
packages/bruno-app/src/components/Devtools/index.js
Normal file
88
packages/bruno-app/src/components/Devtools/index.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Console from './Console';
|
||||
|
||||
const MIN_DEVTOOLS_HEIGHT = 150;
|
||||
const MAX_DEVTOOLS_HEIGHT = window.innerHeight * 0.7;
|
||||
const DEFAULT_DEVTOOLS_HEIGHT = 300;
|
||||
|
||||
const Devtools = ({ mainSectionRef }) => {
|
||||
const isDevtoolsOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
const [devtoolsHeight, setDevtoolsHeight] = useState(DEFAULT_DEVTOOLS_HEIGHT);
|
||||
const [isResizingDevtools, setIsResizingDevtools] = useState(false);
|
||||
|
||||
const handleDevtoolsResizeStart = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsResizingDevtools(true);
|
||||
}, []);
|
||||
|
||||
const handleDevtoolsResize = useCallback((e) => {
|
||||
if (!isResizingDevtools || !mainSectionRef.current) return;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
const statusBarHeight = 22;
|
||||
const mouseY = e.clientY;
|
||||
|
||||
// Calculate new devtools height - expanding upward from bottom
|
||||
const newHeight = windowHeight - mouseY - statusBarHeight;
|
||||
const clampedHeight = Math.min(MAX_DEVTOOLS_HEIGHT, Math.max(MIN_DEVTOOLS_HEIGHT, newHeight));
|
||||
setDevtoolsHeight(clampedHeight);
|
||||
|
||||
// Update main section height
|
||||
if (mainSectionRef.current) {
|
||||
mainSectionRef.current.style.height = `calc(100vh - 22px - ${clampedHeight}px)`;
|
||||
}
|
||||
}, [isResizingDevtools, mainSectionRef]);
|
||||
|
||||
const handleDevtoolsResizeEnd = useCallback(() => {
|
||||
setIsResizingDevtools(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResizingDevtools) {
|
||||
document.addEventListener('mousemove', handleDevtoolsResize);
|
||||
document.addEventListener('mouseup', handleDevtoolsResizeEnd);
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleDevtoolsResize);
|
||||
document.removeEventListener('mouseup', handleDevtoolsResizeEnd);
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}
|
||||
}, [isResizingDevtools, handleDevtoolsResize, handleDevtoolsResizeEnd]);
|
||||
|
||||
// Set initial height
|
||||
useEffect(() => {
|
||||
if (mainSectionRef.current && isDevtoolsOpen) {
|
||||
mainSectionRef.current.style.height = `calc(100vh - 22px - ${devtoolsHeight}px)`;
|
||||
}
|
||||
}, [isDevtoolsOpen, devtoolsHeight, mainSectionRef]);
|
||||
|
||||
if (!isDevtoolsOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleDevtoolsResizeStart}
|
||||
style={{
|
||||
height: '4px',
|
||||
cursor: 'row-resize',
|
||||
backgroundColor: isResizingDevtools ? '#0078d4' : 'transparent',
|
||||
transition: 'background-color 0.2s ease',
|
||||
zIndex: 20,
|
||||
position: 'relative'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.backgroundColor = '#0078d4'}
|
||||
onMouseLeave={(e) => e.target.style.backgroundColor = isResizingDevtools ? '#0078d4' : 'transparent'}
|
||||
/>
|
||||
<div style={{ height: `${devtoolsHeight}px`, overflow: 'hidden', position: 'relative' }}>
|
||||
<Console />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Devtools;
|
||||
@@ -9,7 +9,7 @@ const ManageSecrets = ({ onClose }) => {
|
||||
<div>
|
||||
<p>In any collection, there are secrets that need to be managed.</p>
|
||||
<p className="mt-2">These secrets can be anything such as API keys, passwords, or tokens.</p>
|
||||
<p className="mt-4">Bruno offers two approaches to manage secrets in collections.</p>
|
||||
<p className="mt-4">Bruno offers three approaches to manage secrets in collections.</p>
|
||||
<p className="mt-2">
|
||||
Read more about it in our{' '}
|
||||
<a
|
||||
|
||||
147
packages/bruno-app/src/components/ErrorCapture/index.js
Normal file
147
packages/bruno-app/src/components/ErrorCapture/index.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { Component, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addDebugError } from 'providers/ReduxStore/slices/logs';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
if (this.props.onError) {
|
||||
this.props.onError({
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
error: error,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.setState({ hasError: false });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const serializeArgs = (args) => {
|
||||
return args.map(arg => {
|
||||
try {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
|
||||
return arg;
|
||||
}
|
||||
if (arg instanceof Error) {
|
||||
return {
|
||||
__type: 'Error',
|
||||
name: arg.name,
|
||||
message: arg.message,
|
||||
stack: arg.stack
|
||||
};
|
||||
}
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(arg));
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
} catch (e) {
|
||||
return '[Unserializable]';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to extract file and line info from stack trace
|
||||
const extractFileInfo = (stack) => {
|
||||
if (!stack) return { filename: null, lineno: null, colno: null };
|
||||
|
||||
try {
|
||||
const lines = stack.split('\n');
|
||||
for (let line of lines) {
|
||||
if (line.includes('ErrorCapture') || line.trim() === 'Error') continue;
|
||||
|
||||
const match = line.match(/(?:at\s+.*?\s+)?\(?([^)]+):(\d+):(\d+)\)?/);
|
||||
if (match) {
|
||||
return {
|
||||
filename: match[1],
|
||||
lineno: parseInt(match[2]),
|
||||
colno: parseInt(match[3])
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return { filename: null, lineno: null, colno: null };
|
||||
};
|
||||
|
||||
const useGlobalErrorCapture = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
console.error = (...args) => {
|
||||
const currentStack = new Error().stack;
|
||||
|
||||
originalConsoleError.apply(console, args);
|
||||
|
||||
if (currentStack && currentStack.includes('useIpcEvents.js')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = args.join(' ');
|
||||
if (errorMessage.includes('removeConsoleLogListener')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { filename, lineno, colno } = extractFileInfo(currentStack);
|
||||
|
||||
const serializedArgs = serializeArgs(args);
|
||||
|
||||
dispatch(addDebugError({
|
||||
message: errorMessage,
|
||||
stack: currentStack,
|
||||
filename: filename,
|
||||
lineno: lineno,
|
||||
colno: colno,
|
||||
args: serializedArgs,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.error = originalConsoleError;
|
||||
};
|
||||
}, [dispatch]);
|
||||
};
|
||||
|
||||
const ErrorCapture = ({ children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useGlobalErrorCapture();
|
||||
|
||||
const handleReactError = (errorData) => {
|
||||
dispatch(addDebugError(errorData));
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary onError={handleReactError}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorCapture;
|
||||
@@ -11,6 +11,12 @@ const Wrapper = styled.div`
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
.inherit-mode-text {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
.auth-mode-label {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -7,8 +7,17 @@ import { updateFolderAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
|
||||
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
|
||||
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
|
||||
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
|
||||
import AuthMode from '../AuthMode';
|
||||
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
|
||||
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
|
||||
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
|
||||
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -27,6 +36,8 @@ const GrantTypeComponentMap = ({ collection, folder }) => {
|
||||
return <OAuth2AuthorizationCode save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
|
||||
case 'client_credentials':
|
||||
return <OAuth2ClientCredentials save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
|
||||
case 'implicit':
|
||||
return <OAuth2Implicit save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
|
||||
default:
|
||||
return <div>TBD</div>;
|
||||
}
|
||||
@@ -37,12 +48,132 @@ const Auth = ({ collection, folder }) => {
|
||||
let request = get(folder, 'root.request', {});
|
||||
const authMode = get(folder, 'root.request.auth.mode');
|
||||
|
||||
const getTreePathFromCollectionToFolder = (collection, _folder) => {
|
||||
let path = [];
|
||||
let item = findItemInCollection(collection, _folder?.uid);
|
||||
while (item) {
|
||||
path.unshift(item);
|
||||
item = findParentItemInCollection(collection, item?.uid);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionAuth = get(collection, 'root.request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Get path from collection to current folder
|
||||
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
|
||||
|
||||
// Check parent folders to find closest auth configuration
|
||||
// Skip the last item which is the current folder
|
||||
for (let i = 0; i < folderTreePath.length - 1; i++) {
|
||||
const parentFolder = folderTreePath[i];
|
||||
if (parentFolder.type === 'folder') {
|
||||
const folderAuth = get(parentFolder, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: parentFolder.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'basic': {
|
||||
return (
|
||||
<BasicAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'bearer': {
|
||||
return (
|
||||
<BearerAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'digest': {
|
||||
return (
|
||||
<DigestAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'ntlm': {
|
||||
return (
|
||||
<NTLMAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'wsse': {
|
||||
return (
|
||||
<WsseAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'apikey': {
|
||||
return (
|
||||
<ApiKeyAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'awsv4': {
|
||||
return (
|
||||
<AwsV4Auth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'oauth2': {
|
||||
return (
|
||||
<>
|
||||
@@ -56,6 +187,17 @@ const Auth = ({ collection, folder }) => {
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'none': {
|
||||
return null;
|
||||
}
|
||||
@@ -64,6 +206,7 @@ const Auth = ({ collection, folder }) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
|
||||
@@ -35,6 +35,51 @@ const AuthMode = ({ collection, folder }) => {
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('awsv4');
|
||||
}}
|
||||
>
|
||||
AWS Sig v4
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('basic');
|
||||
}}
|
||||
>
|
||||
Basic Auth
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('bearer');
|
||||
}}
|
||||
>
|
||||
Bearer Token
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('digest');
|
||||
}}
|
||||
>
|
||||
Digest Auth
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('ntlm');
|
||||
}}
|
||||
>
|
||||
NTLM Auth
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
@@ -44,6 +89,33 @@ const AuthMode = ({ collection, folder }) => {
|
||||
>
|
||||
OAuth 2.0
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('wsse');
|
||||
}}
|
||||
>
|
||||
WSSE Auth
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('apikey');
|
||||
}}
|
||||
>
|
||||
API Key
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('inherit');
|
||||
}}
|
||||
>
|
||||
Inherit
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
const Headers = ({ collection, folder }) => {
|
||||
@@ -117,6 +118,7 @@ const Headers = ({ collection, folder }) => {
|
||||
}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
autocomplete={MimeTypes}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -55,6 +55,7 @@ const Script = ({ collection, folder }) => {
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-2 gap-y-2">
|
||||
@@ -68,6 +69,7 @@ const Script = ({ collection, folder }) => {
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ const Tests = ({ collection, folder }) => {
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -9,17 +9,9 @@ import StyledWrapper from './StyledWrapper';
|
||||
import Vars from './Vars';
|
||||
import Documentation from './Documentation';
|
||||
import Auth from './Auth';
|
||||
import DotIcon from 'components/Icons/Dot';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const ContentIndicator = () => {
|
||||
return (
|
||||
<sup className="ml-[.125rem] opacity-80 font-medium">
|
||||
<DotIcon width="10"></DotIcon>
|
||||
</sup>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderSettings = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
let tab = 'headers';
|
||||
@@ -82,7 +74,7 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full">
|
||||
<StyledWrapper className="flex flex-col h-full overflow-scroll">
|
||||
<div className="flex flex-col h-full relative px-4 py-4">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
@@ -91,11 +83,11 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <ContentIndicator />}
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
|
||||
Test
|
||||
{hasTests && <ContentIndicator />}
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
@@ -103,13 +95,13 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <ContentIndicator />}
|
||||
{hasAuth && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
</div>
|
||||
</div>
|
||||
<section className={`flex mt-4 h-full`}>{getTabPanel(tab)}</section>
|
||||
<section className={`flex mt-4 h-full overflow-scroll`}>{getTabPanel(tab)}</section>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,10 @@ import React, { Component } from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
}
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
class MultiLineEditor extends Component {
|
||||
constructor(props) {
|
||||
@@ -78,14 +74,23 @@ class MultiLineEditor extends Component {
|
||||
'Shift-Tab': false
|
||||
}
|
||||
});
|
||||
if (this.props.autocomplete) {
|
||||
this.editor.on('keyup', (cm, event) => {
|
||||
if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
|
||||
/*Enter - do not open autocomplete list just after item has been selected in it*/
|
||||
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
|
||||
const getAnywordAutocompleteHints = () => this.props.autocomplete || [];
|
||||
|
||||
// Setup AutoComplete Helper
|
||||
const autoCompleteOptions = {
|
||||
showHintsFor: ['variables'],
|
||||
getAllVariables: getAllVariablesHandler,
|
||||
getAnywordAutocompleteHints
|
||||
};
|
||||
|
||||
this.brunoAutoCompleteCleanup = setupAutoComplete(
|
||||
this.editor,
|
||||
autoCompleteOptions
|
||||
);
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
@@ -125,12 +130,15 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
|
||||
addOverlay = (variables) => {
|
||||
this.variables = variables;
|
||||
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
|
||||
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IconBell } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyleWrapper';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal';
|
||||
import { useEffect } from 'react';
|
||||
import { useApp } from 'providers/App';
|
||||
import {
|
||||
@@ -109,7 +110,7 @@ const Notifications = () => {
|
||||
>
|
||||
<ToolHint text="Notifications" toolhintId="Notifications" offset={8}>
|
||||
<IconBell
|
||||
size={18}
|
||||
size={16}
|
||||
aria-hidden
|
||||
strokeWidth={1.5}
|
||||
className={`mr-2 ${unreadNotifications?.length > 0 ? 'bell' : ''}`}
|
||||
@@ -121,6 +122,7 @@ const Notifications = () => {
|
||||
</a>
|
||||
|
||||
{showNotificationsModal && (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="lg"
|
||||
title="Notifications"
|
||||
@@ -199,10 +201,11 @@ const Notifications = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="opacity-50 italic text-xs p-12 flex justify-center">No Notifications</div>
|
||||
<div className="opacity-50 italic text-xs p-12 flex justify-center">You are all caught up!</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Font = ({ close }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -31,7 +32,10 @@ const Font = ({ close }) => {
|
||||
}
|
||||
})
|
||||
).then(() => {
|
||||
toast.success('Preferences saved successfully')
|
||||
close();
|
||||
}).catch(() => {
|
||||
toast.error('Failed to save preferences')
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -80,9 +80,9 @@ const General = ({ close }) => {
|
||||
storeCookies: newPreferences.storeCookies,
|
||||
sendCookies: newPreferences.sendCookies
|
||||
}
|
||||
})
|
||||
)
|
||||
}))
|
||||
.then(() => {
|
||||
toast.success('Preferences saved successfully')
|
||||
close();
|
||||
})
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
|
||||
|
||||
@@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => {
|
||||
proxy: validatedProxy
|
||||
})
|
||||
).then(() => {
|
||||
toast.success('Preferences saved successfully')
|
||||
close();
|
||||
}).catch(() => {
|
||||
toast.error('Failed to save preferences')
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
|
||||
|
||||
const ApiKeyAuth = ({ item, collection }) => {
|
||||
const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
|
||||
const apikeyAuth = get(request, 'auth.apikey', {});
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
@@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
dropdownTippyRef?.current?.hide();
|
||||
handleAuthChange('placement', 'header');
|
||||
}}
|
||||
>
|
||||
@@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
dropdownTippyRef?.current?.hide();
|
||||
handleAuthChange('placement', 'queryparams');
|
||||
}}
|
||||
>
|
||||
Query Params
|
||||
Query Param
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { update } from 'lodash';
|
||||
|
||||
const AwsV4Auth = ({ onTokenChange, item, collection }) => {
|
||||
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
|
||||
const awsv4Auth = get(request, 'auth.awsv4', {});
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleAccessKeyIdChange = (accessKeyId) => {
|
||||
dispatch(
|
||||
@@ -25,11 +28,11 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
accessKeyId: accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -42,12 +45,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -60,12 +63,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -78,12 +81,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -96,12 +99,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: region,
|
||||
profileName: awsv4Auth.profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: region || '',
|
||||
profileName: awsv4Auth.profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -114,12 +117,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
accessKeyId: awsv4Auth.accessKeyId,
|
||||
secretAccessKey: awsv4Auth.secretAccessKey,
|
||||
sessionToken: awsv4Auth.sessionToken,
|
||||
service: awsv4Auth.service,
|
||||
region: awsv4Auth.region,
|
||||
profileName: profileName
|
||||
accessKeyId: awsv4Auth.accessKeyId || '',
|
||||
secretAccessKey: awsv4Auth.secretAccessKey || '',
|
||||
sessionToken: awsv4Auth.sessionToken || '',
|
||||
service: awsv4Auth.service || '',
|
||||
region: awsv4Auth.region || '',
|
||||
profileName: profileName || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const BasicAuth = ({ item, collection }) => {
|
||||
const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
|
||||
const basicAuth = get(request, 'auth.basic', {});
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleUsernameChange = (username) => {
|
||||
dispatch(
|
||||
@@ -23,8 +26,8 @@ const BasicAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: basicAuth.password
|
||||
username: username || '',
|
||||
password: basicAuth.password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -37,8 +40,8 @@ const BasicAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: basicAuth.username,
|
||||
password: password
|
||||
username: basicAuth.username || '',
|
||||
password: password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const BearerAuth = ({ item, collection }) => {
|
||||
const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const bearerToken = item.draft
|
||||
? get(item, 'draft.request.auth.bearer.token', '')
|
||||
: get(item, 'request.auth.bearer.token', '');
|
||||
// Use the request prop directly like OAuth2ClientCredentials does
|
||||
const bearerToken = get(request, 'auth.bearer.token', '');
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleTokenChange = (token) => {
|
||||
dispatch(
|
||||
|
||||
@@ -3,18 +3,20 @@ import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DigestAuth = ({ item, collection }) => {
|
||||
const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
|
||||
const digestAuth = get(request, 'auth.digest', {});
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleUsernameChange = (username) => {
|
||||
dispatch(
|
||||
@@ -23,8 +25,8 @@ const DigestAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: digestAuth.password
|
||||
username: username || '',
|
||||
password: digestAuth.password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -37,8 +39,8 @@ const DigestAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: digestAuth.username,
|
||||
password: password
|
||||
username: digestAuth.username || '',
|
||||
password: password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const NTLMAuth = ({ item, collection }) => {
|
||||
const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {});
|
||||
const ntlmAuth = get(request, 'auth.ntlm', {});
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleUsernameChange = (username) => {
|
||||
dispatch(
|
||||
@@ -23,10 +26,9 @@ const NTLMAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: ntlmAuth.password,
|
||||
domain: ntlmAuth.domain
|
||||
|
||||
username: username || '',
|
||||
password: ntlmAuth.password || '',
|
||||
domain: ntlmAuth.domain || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -39,9 +41,9 @@ const NTLMAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: ntlmAuth.username,
|
||||
password: password,
|
||||
domain: ntlmAuth.domain
|
||||
username: ntlmAuth.username || '',
|
||||
password: password || '',
|
||||
domain: ntlmAuth.domain || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -54,9 +56,9 @@ const NTLMAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: ntlmAuth.username,
|
||||
password: ntlmAuth.password,
|
||||
domain: domain
|
||||
username: ntlmAuth.username || '',
|
||||
password: ntlmAuth.password || '',
|
||||
domain: domain || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -101,6 +101,15 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
>
|
||||
Authorization Code
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('implicit');
|
||||
}}
|
||||
>
|
||||
Implicit
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.oauth2-input-wrapper {
|
||||
max-width: 400px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.token-placement-selector {
|
||||
padding: 0.5rem 0px;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
min-width: 100px;
|
||||
|
||||
.dropdown {
|
||||
width: fit-content;
|
||||
min-width: 100px;
|
||||
|
||||
div[data-tippy-root] {
|
||||
width: fit-content;
|
||||
min-width: 100px;
|
||||
}
|
||||
.tippy-box {
|
||||
width: fit-content;
|
||||
max-width: none !important;
|
||||
min-width: 100px;
|
||||
|
||||
.tippy-content {
|
||||
width: fit-content;
|
||||
max-width: none !important;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.token-placement-label {
|
||||
width: fit-content;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.5rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.2rem 0.6rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: ${(props) => props.theme.colors.text.primary};
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useRef, forwardRef, useState, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { clearOauth2Cache, fetchOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Wrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import toast from 'react-hot-toast';
|
||||
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { getAllVariables } from 'utils/collections/index';
|
||||
import { interpolate } from '@usebruno/common';
|
||||
|
||||
const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const [fetchingToken, toggleFetchingToken] = useState(false);
|
||||
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const {
|
||||
callbackUrl,
|
||||
authorizationUrl,
|
||||
clientId,
|
||||
scope,
|
||||
state,
|
||||
credentialsId,
|
||||
tokenPlacement,
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken
|
||||
} = oAuth;
|
||||
|
||||
const interpolatedAuthUrl = useMemo(() => {
|
||||
const variables = getAllVariables(collection, item);
|
||||
return interpolate(authorizationUrl, variables);
|
||||
}, [collection, item, authorizationUrl]);
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleFetchOauth2Credentials = async () => {
|
||||
let requestCopy = cloneDeep(request);
|
||||
requestCopy.oauth2 = requestCopy?.auth.oauth2;
|
||||
requestCopy.headers = {};
|
||||
toggleFetchingToken(true);
|
||||
try {
|
||||
const result = await dispatch(fetchOauth2Credentials({
|
||||
itemUid: item.uid,
|
||||
request: requestCopy,
|
||||
collection,
|
||||
folderUid: folder?.uid || null,
|
||||
forceGetToken: true
|
||||
}));
|
||||
|
||||
toggleFetchingToken(false);
|
||||
|
||||
// Check if the result contains error or if access_token is missing
|
||||
if (result?.error || !result?.access_token) {
|
||||
const errorMessage = result?.error || 'No access token received from authorization server';
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Token fetched successfully!');
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
toggleFetchingToken(false);
|
||||
toast.error(error?.message || 'An error occurred while fetching token!');
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'oauth2',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
grantType: 'implicit',
|
||||
callbackUrl,
|
||||
authorizationUrl,
|
||||
clientId,
|
||||
state,
|
||||
scope,
|
||||
credentialsId,
|
||||
tokenPlacement,
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken,
|
||||
[key]: value,
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAutoFetchTokenToggle = (e) => {
|
||||
handleChange('autoFetchToken', e.target.checked);
|
||||
};
|
||||
|
||||
const handleClearCache = (e) => {
|
||||
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAuthUrl, credentialsId }))
|
||||
.then(() => {
|
||||
toast.success('Cleared cache successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper className="mt-2 flex w-full gap-4 flex-col">
|
||||
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={authorizationUrl} credentialsId={credentialsId} />
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
Configuration
|
||||
</span>
|
||||
</div>
|
||||
{inputsConfig.map((input) => {
|
||||
const { key, label, isSecret } = input;
|
||||
return (
|
||||
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
|
||||
<label className="block min-w-[140px]">{label}</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oAuth[key] || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange(key, val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oAuth['credentialsId'] || 'credentials'}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('credentialsId', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
|
||||
<label className="block min-w-[140px]">Add Token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Headers
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tokenPlacement == 'header' ? (
|
||||
<div className="flex items-center gap-4 w-full" key={`input-token-header-prefix`}>
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oAuth.tokenHeaderPrefix || 'Bearer'}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 w-full" key={`input-token-query-key`}>
|
||||
<label className="block min-w-[140px]">URL Query Key</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oAuth.tokenQueryKey || 'access_token'}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('tokenQueryKey', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
Advanced Options
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oAuth.autoFetchToken !== false}
|
||||
onChange={handleAutoFetchTokenToggle}
|
||||
className="cursor-pointer ml-1"
|
||||
/>
|
||||
<label className="block min-w-[140px]">Auto fetch token</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative group cursor-pointer">
|
||||
<IconHelp size={16} className="text-gray-500" />
|
||||
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
|
||||
Automatically fetch a new token when the current one expires.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 mt-4">
|
||||
<button
|
||||
onClick={handleFetchOauth2Credentials}
|
||||
className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}
|
||||
disabled={fetchingToken}
|
||||
>
|
||||
Get Access Token{fetchingToken ? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
|
||||
</button>
|
||||
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
|
||||
Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth2Implicit;
|
||||
@@ -0,0 +1,24 @@
|
||||
const inputsConfig = [
|
||||
{
|
||||
key: 'callbackUrl',
|
||||
label: 'Callback URL'
|
||||
},
|
||||
{
|
||||
key: 'authorizationUrl',
|
||||
label: 'Authorization URL'
|
||||
},
|
||||
{
|
||||
key: 'clientId',
|
||||
label: 'Client ID'
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
label: 'Scope'
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
label: 'State'
|
||||
}
|
||||
];
|
||||
|
||||
export { inputsConfig };
|
||||
@@ -28,20 +28,30 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
|
||||
requestCopy.headers = {};
|
||||
toggleFetchingToken(true);
|
||||
try {
|
||||
const credentials = await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
|
||||
const result = await dispatch(fetchOauth2Credentials({
|
||||
itemUid: item.uid,
|
||||
request: requestCopy,
|
||||
collection,
|
||||
forceGetToken: true
|
||||
}));
|
||||
|
||||
toggleFetchingToken(false);
|
||||
if (credentials?.access_token) {
|
||||
toast.success('token fetched successfully!');
|
||||
}
|
||||
else {
|
||||
toast.error('An error occurred while fetching token!');
|
||||
|
||||
// Check if the result contains error or if access_token is missing
|
||||
if (!result || !result.access_token) {
|
||||
const errorMessage = result?.error || 'No access token received from authorization server';
|
||||
console.error(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Token fetched successfully!');
|
||||
}
|
||||
catch (error) {
|
||||
console.error('could not fetch the token!');
|
||||
console.error(error);
|
||||
toggleFetchingToken(false);
|
||||
toast.error('An error occurred while fetching token!');
|
||||
toast.error(error?.message || 'An error occurred while fetching token!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,26 +61,36 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
|
||||
requestCopy.headers = {};
|
||||
toggleRefreshingToken(true);
|
||||
try {
|
||||
const credentials = await dispatch(refreshOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
|
||||
const result = await dispatch(refreshOauth2Credentials({
|
||||
itemUid: item.uid,
|
||||
request: requestCopy,
|
||||
collection,
|
||||
forceGetToken: true
|
||||
}));
|
||||
|
||||
toggleRefreshingToken(false);
|
||||
if (credentials?.access_token) {
|
||||
toast.success('token refreshed successfully!');
|
||||
}
|
||||
else {
|
||||
toast.error('An error occurred while refreshing token!');
|
||||
|
||||
// Check if the result contains error or if access_token is missing
|
||||
if (!result || !result.access_token) {
|
||||
const errorMessage = result?.error || 'No access token received from authorization server';
|
||||
console.error(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Token refreshed successfully!');
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
toggleRefreshingToken(false);
|
||||
toast.error('An error occurred while refreshing token!');
|
||||
toast.error(error?.message || 'An error occurred while refreshing token!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCache = (e) => {
|
||||
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
|
||||
.then(() => {
|
||||
toast.success('cleared cache successfully');
|
||||
toast.success('Cleared cache successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -4,6 +4,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import GrantTypeSelector from './GrantTypeSelector/index';
|
||||
import OAuth2PasswordCredentials from './PasswordCredentials/index';
|
||||
import OAuth2AuthorizationCode from './AuthorizationCode/index';
|
||||
import OAuth2Implicit from './Implicit/index';
|
||||
import OAuth2ClientCredentials from './ClientCredentials/index';
|
||||
import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -31,6 +32,9 @@ const GrantTypeComponentMap = ({ item, collection }) => {
|
||||
case 'authorization_code':
|
||||
return <OAuth2AuthorizationCode item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
|
||||
break;
|
||||
case 'implicit':
|
||||
return <OAuth2Implicit item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
|
||||
break;
|
||||
case 'client_credentials':
|
||||
return <OAuth2ClientCredentials item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
|
||||
break;
|
||||
|
||||
@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const WsseAuth = ({ item, collection }) => {
|
||||
const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
|
||||
const wsseAuth = get(request, 'auth.wsse', {});
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleUserChange = (username) => {
|
||||
dispatch(
|
||||
@@ -23,8 +26,8 @@ const WsseAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username,
|
||||
password: wsseAuth.password
|
||||
username: username || '',
|
||||
password: wsseAuth.password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -37,8 +40,8 @@ const WsseAuth = ({ item, collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: wsseAuth.username,
|
||||
password
|
||||
username: wsseAuth.username || '',
|
||||
password: password || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -55,6 +58,7 @@ const WsseAuth = ({ item, collection }) => {
|
||||
onChange={(val) => handleUserChange(val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +71,8 @@ const WsseAuth = ({ item, collection }) => {
|
||||
onChange={(val) => handlePasswordChange(val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={true}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -7,6 +7,8 @@ import BasicAuth from './BasicAuth';
|
||||
import DigestAuth from './DigestAuth';
|
||||
import WsseAuth from './WsseAuth';
|
||||
import NTLMAuth from './NTLMAuth';
|
||||
import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
import ApiKeyAuth from './ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -27,6 +29,16 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
|
||||
const Auth = ({ item, collection }) => {
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
// Create a request object to pass to the auth components
|
||||
const request = item.draft
|
||||
? get(item, 'draft.request', {})
|
||||
: get(item, 'request', {});
|
||||
|
||||
// Save function for request level
|
||||
const save = () => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
};
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
@@ -42,7 +54,7 @@ const Auth = ({ item, collection }) => {
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
@@ -59,28 +71,28 @@ const Auth = ({ item, collection }) => {
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'awsv4': {
|
||||
return <AwsV4Auth collection={collection} item={item} />;
|
||||
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'basic': {
|
||||
return <BasicAuth collection={collection} item={item} />;
|
||||
return <BasicAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'bearer': {
|
||||
return <BearerAuth collection={collection} item={item} />;
|
||||
return <BearerAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'digest': {
|
||||
return <DigestAuth collection={collection} item={item} />;
|
||||
return <DigestAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'ntlm': {
|
||||
return <NTLMAuth collection={collection} item={item} />;
|
||||
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} item={item} />;
|
||||
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'wsse': {
|
||||
return <WsseAuth collection={collection} item={item} />;
|
||||
return <WsseAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'apikey': {
|
||||
return <ApiKeyAuth collection={collection} item={item} />;
|
||||
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
@@ -18,8 +18,10 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
|
||||
const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
|
||||
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
@@ -66,7 +68,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
schema={schema}
|
||||
width={leftPaneWidth}
|
||||
onSave={onSave}
|
||||
value={query}
|
||||
onRun={onRun}
|
||||
@@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
|
||||
case 'docs': {
|
||||
return <Documentation item={item} collection={collection} />;
|
||||
}
|
||||
case 'settings': {
|
||||
return <Settings item={item} collection={collection} />;
|
||||
}
|
||||
default: {
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
@@ -152,9 +156,14 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
|
||||
Docs
|
||||
</div>
|
||||
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
|
||||
Settings
|
||||
</div>
|
||||
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
|
||||
</div>
|
||||
<section className="flex w-full mt-5 flex-1 relative">{getTabPanel(focusedTab.requestPaneTab)}</section>
|
||||
<section className="flex w-full mt-5 flex-1 relative">
|
||||
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown';
|
||||
|
||||
const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => {
|
||||
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
|
||||
const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
|
||||
const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
|
||||
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
|
||||
|
||||
let {
|
||||
schema,
|
||||
|
||||
@@ -64,9 +64,11 @@ const GraphQLVariables = ({ variables, item, collection }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
mode="application/json"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders';
|
||||
import RequestBody from 'components/RequestPane/RequestBody';
|
||||
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
|
||||
import Auth from 'components/RequestPane/Auth';
|
||||
import DotIcon from 'components/Icons/Dot';
|
||||
import Vars from 'components/RequestPane/Vars';
|
||||
import Assertions from 'components/RequestPane/Assertions';
|
||||
import Script from 'components/RequestPane/Script';
|
||||
@@ -15,25 +14,12 @@ import Tests from 'components/RequestPane/Tests';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { find, get } from 'lodash';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import { useEffect } from 'react';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
|
||||
const ContentIndicator = () => {
|
||||
return (
|
||||
<sup className="ml-[.125rem] opacity-80 font-medium">
|
||||
<DotIcon width="10"></DotIcon>
|
||||
</sup>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorIndicator = () => {
|
||||
return (
|
||||
<sup className="ml-[.125rem] opacity-80 font-medium text-red-500">
|
||||
<DotIcon width="10" ></DotIcon>
|
||||
</sup>
|
||||
);
|
||||
};
|
||||
|
||||
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
const HttpRequestPane = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
@@ -76,6 +62,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
case 'docs': {
|
||||
return <Documentation item={item} collection={collection} />;
|
||||
}
|
||||
case 'settings': {
|
||||
return <Settings item={item} collection={collection} />;
|
||||
}
|
||||
default: {
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
@@ -112,6 +101,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
|
||||
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
|
||||
const auth = getPropertyFromDraftOrRequest('request.auth');
|
||||
const tags = getPropertyFromDraftOrRequest('tags');
|
||||
|
||||
const activeParamsLength = params.filter((param) => param.enabled).length;
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
@@ -135,7 +125,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
|
||||
Body
|
||||
{body.mode !== 'none' && <ContentIndicator />}
|
||||
{body.mode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
|
||||
Headers
|
||||
@@ -143,7 +133,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
|
||||
Auth
|
||||
{auth.mode !== 'none' && <ContentIndicator />}
|
||||
{auth.mode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
|
||||
Vars
|
||||
@@ -152,9 +142,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
|
||||
Script
|
||||
{(script.req || script.res) && (
|
||||
item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ?
|
||||
<ErrorIndicator /> :
|
||||
<ContentIndicator />
|
||||
item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ?
|
||||
<StatusDot type="error" /> :
|
||||
<StatusDot />
|
||||
)}
|
||||
</div>
|
||||
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
|
||||
@@ -163,11 +153,19 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
|
||||
Tests
|
||||
{tests && tests.length > 0 && <ContentIndicator />}
|
||||
{tests && tests.length > 0 && (
|
||||
item.testScriptErrorMessage ?
|
||||
<StatusDot type="error" /> :
|
||||
<StatusDot />
|
||||
)}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
|
||||
Docs
|
||||
{docs && docs.length > 0 && <ContentIndicator />}
|
||||
{docs && docs.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
|
||||
Settings
|
||||
{tags && tags.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
{focusedTab.requestPaneTab === 'body' ? (
|
||||
<div className="flex flex-grow justify-end items-center">
|
||||
@@ -180,7 +178,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
'mt-5': !isMultipleContentTab
|
||||
})}
|
||||
>
|
||||
{getTabPanel(focusedTab.requestPaneTab)}
|
||||
<HeightBoundContainer>
|
||||
{getTabPanel(focusedTab.requestPaneTab)}
|
||||
</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -18,12 +18,7 @@ import { IconWand } from '@tabler/icons';
|
||||
|
||||
import onHasCompletion from './onHasCompletion';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
}
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
const md = new MD();
|
||||
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;
|
||||
|
||||
@@ -31,7 +31,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
.btn-action {
|
||||
font-size: 0.8125rem;
|
||||
&:hover span {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import {
|
||||
addQueryParam,
|
||||
updateQueryParam,
|
||||
deleteQueryParam,
|
||||
moveQueryParam,
|
||||
updatePathParam
|
||||
updatePathParam,
|
||||
setQueryParams
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -18,6 +19,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Table from 'components/Table/index';
|
||||
import ReorderTable from 'components/ReorderTable';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
|
||||
const QueryParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -25,6 +27,8 @@ const QueryParams = ({ item, collection }) => {
|
||||
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
|
||||
const queryParams = params.filter((param) => param.type === 'query');
|
||||
const pathParams = params.filter((param) => param.type === 'path');
|
||||
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const handleAddQueryParam = () => {
|
||||
dispatch(
|
||||
@@ -113,8 +117,31 @@ const QueryParams = ({ item, collection }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkParamsChange = (newParams) => {
|
||||
const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' }));
|
||||
dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType }));
|
||||
};
|
||||
|
||||
if (isBulkEditMode) {
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-3">
|
||||
<BulkEditor
|
||||
params={queryParams}
|
||||
onChange={handleBulkParamsChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
onSave={onSave}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col absolute">
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Query</div>
|
||||
<Table
|
||||
@@ -171,9 +198,14 @@ const QueryParams = ({ item, collection }) => {
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddQueryParam}>
|
||||
+ <span>Add Param</span>
|
||||
</button>
|
||||
<div className="flex justify-between mt-2">
|
||||
<button className="btn-action text-link pr-2 py-3 select-none" onClick={handleAddQueryParam}>
|
||||
+ <span>Add Param</span>
|
||||
</button>
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 title text-xs flex items-stretch">
|
||||
<span>Path</span>
|
||||
<InfoTip infotipId="path-param-InfoTip">
|
||||
|
||||
@@ -83,6 +83,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
|
||||
</div>
|
||||
<div
|
||||
id="request-url"
|
||||
className="flex items-center flex-grow input-container h-full"
|
||||
style={{
|
||||
color: 'yellow',
|
||||
|
||||
@@ -21,7 +21,7 @@ const RequestBodyMode = ({ item, collection }) => {
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
|
||||
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
|
||||
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -149,7 +149,7 @@ const RequestBodyMode = ({ item, collection }) => {
|
||||
</Dropdown>
|
||||
</div>
|
||||
{(bodyMode === 'json' || bodyMode === 'xml') && (
|
||||
<button className="ml-1" onClick={onPrettify}>
|
||||
<button className="ml-2" onClick={onPrettify}>
|
||||
Prettify
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -49,7 +49,7 @@ const RequestBody = ({ item, collection }) => {
|
||||
<StyledWrapper className="w-full">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
item={item}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
@@ -58,13 +58,15 @@ const RequestBody = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
mode={codeMirrorMode[bodyMode]}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyMode === 'file') {
|
||||
return <FileBody item={item} collection={collection}/>
|
||||
return <FileBody item={item} collection={collection} />;
|
||||
}
|
||||
|
||||
if (bodyMode === 'formUrlEncoded') {
|
||||
@@ -77,4 +79,4 @@ const RequestBody = ({ item, collection }) => {
|
||||
|
||||
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
|
||||
};
|
||||
export default RequestBody;
|
||||
export default RequestBody;
|
||||
|
||||
@@ -22,8 +22,11 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-header {
|
||||
.btn-action {
|
||||
font-size: 0.8125rem;
|
||||
&:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections';
|
||||
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -12,12 +12,16 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import Table from 'components/Table/index';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
const RequestHeaders = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
|
||||
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const addHeader = () => {
|
||||
dispatch(
|
||||
@@ -75,6 +79,28 @@ const RequestHeaders = ({ item, collection }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkHeadersChange = (newHeaders) => {
|
||||
dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
|
||||
};
|
||||
|
||||
if (isBulkEditMode) {
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-3">
|
||||
<BulkEditor
|
||||
params={headers}
|
||||
onChange={handleBulkHeadersChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
onSave={onSave}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Table
|
||||
@@ -153,9 +179,14 @@ const RequestHeaders = ({ item, collection }) => {
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
|
||||
+ Add Header
|
||||
</button>
|
||||
<div className="flex justify-between mt-2">
|
||||
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
|
||||
+ Add Header
|
||||
</button>
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,6 +52,7 @@ const Script = ({ item, collection }) => {
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-2 gap-y-2">
|
||||
@@ -66,6 +67,7 @@ const Script = ({ item, collection }) => {
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addRequestTag, deleteRequestTag, updateCollectionTagsList } from 'providers/ReduxStore/slices/collections';
|
||||
import TagList from 'components/TagList/index';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const Tags = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
// all tags in the collection
|
||||
const collectionTags = collection.allTags || [];
|
||||
|
||||
// tags for the current request
|
||||
const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []);
|
||||
|
||||
// Filter out tags that are already associated with the current request
|
||||
const collectionTagsWithoutCurrentRequestTags = collectionTags?.filter(tag => !tags.includes(tag)) || [];
|
||||
|
||||
const handleAdd = useCallback((tag) => {
|
||||
const trimmedTag = tag.trim();
|
||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
||||
dispatch(
|
||||
addRequestTag({
|
||||
tag: trimmedTag,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [dispatch, tags, item.uid, collection.uid]);
|
||||
|
||||
const handleRemove = useCallback((tag) => {
|
||||
dispatch(
|
||||
deleteRequestTag({
|
||||
tag,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}, [dispatch, item.uid, collection.uid]);
|
||||
|
||||
const handleRequestSave = () => {
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(updateCollectionTagsList({ collectionUid: collection.uid }));
|
||||
}, [collection.uid, dispatch]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<TagList
|
||||
tagsHintList={collectionTagsWithoutCurrentRequestTags}
|
||||
handleAddTag={handleAdd}
|
||||
handleRemoveTag={handleRemove}
|
||||
tags={tags}
|
||||
onSave={handleRequestSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tags;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
|
||||
const ToggleSelector = ({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
size = 'small' // 'small', 'medium', 'large'
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
small: {
|
||||
container: 'h-4 w-8',
|
||||
thumb: 'h-3 w-3',
|
||||
translate: checked ? 'translate-x-4' : 'translate-x-1'
|
||||
},
|
||||
medium: {
|
||||
container: 'h-5 w-9',
|
||||
thumb: 'h-3 w-3',
|
||||
translate: checked ? 'translate-x-5' : 'translate-x-1'
|
||||
},
|
||||
large: {
|
||||
container: 'h-6 w-11',
|
||||
thumb: 'h-4 w-4',
|
||||
translate: checked ? 'translate-x-6' : 'translate-x-1'
|
||||
}
|
||||
};
|
||||
|
||||
const currentSize = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative inline-flex ${currentSize.container} mx-1 items-center rounded-full transition-colors
|
||||
focus:outline-none focus:ring-1 focus:ring-offset-1
|
||||
${disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
}
|
||||
${checked
|
||||
? 'bg-blue-600 dark:bg-blue-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block ${currentSize.thumb} transform rounded-full bg-white transition-transform
|
||||
${currentSize.translate}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-700 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleSelector;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { IconTag } from '@tabler/icons';
|
||||
import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
|
||||
import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
|
||||
import Tags from './Tags/index';
|
||||
|
||||
const Settings = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
|
||||
const getPropertyFromDraftOrRequest = (propertyKey) =>
|
||||
item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {});
|
||||
|
||||
const { encodeUrl } = getPropertyFromDraftOrRequest('settings');
|
||||
|
||||
const onToggleUrlEncoding = useCallback(() => {
|
||||
dispatch(updateItemSettings({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
settings: { encodeUrl: !encodeUrl }
|
||||
}));
|
||||
}, [encodeUrl, dispatch, collection.uid, item.uid]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-10">
|
||||
<div className='flex flex-col gap-2 max-w-[400px]'>
|
||||
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||
<IconTag size={16} />
|
||||
Tags
|
||||
</h3>
|
||||
<div label="Tags">
|
||||
<Tags item={item} collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<ToggleSelector
|
||||
checked={encodeUrl}
|
||||
onChange={onToggleUrlEncoding}
|
||||
label="URL Encoding"
|
||||
description="Automatically encode query parameters in the URL"
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -37,6 +37,7 @@ const Tests = ({ item, collection }) => {
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,9 +3,13 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
&.dragging {
|
||||
cursor: col-resize;
|
||||
|
||||
&.vertical-layout {
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
||||
|
||||
div.drag-request {
|
||||
div.dragbar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -15,18 +19,47 @@ const StyledWrapper = styled.div`
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
|
||||
div.drag-request-border {
|
||||
div.dragbar-handle {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.drag-request-border {
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical-layout {
|
||||
.request-pane {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.response-pane {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: row-resize;
|
||||
padding: 0 1rem;
|
||||
|
||||
div.dragbar-handle {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.graphql-docs-explorer-container {
|
||||
background: white;
|
||||
outline: none;
|
||||
|
||||
@@ -29,7 +29,8 @@ import FolderNotFound from './FolderNotFound';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
const DEFAULT_PADDING = 5;
|
||||
const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_BOTTOM_PANE_HEIGHT = 150;
|
||||
|
||||
const RequestTabPanel = () => {
|
||||
if (typeof window == 'undefined') {
|
||||
@@ -41,6 +42,8 @@ const RequestTabPanel = () => {
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const _collections = useSelector((state) => state.collections.collections);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
|
||||
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
|
||||
let collections = produce(_collections, (draft) => {
|
||||
@@ -64,13 +67,15 @@ const RequestTabPanel = () => {
|
||||
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const [leftPaneWidth, setLeftPaneWidth] = useState(
|
||||
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
|
||||
); // 2.2 so that request pane is relatively smaller
|
||||
const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
|
||||
); // 2.2 is intentional to make both panes appear to be of equal width
|
||||
const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Not a recommended pattern here to have the child component
|
||||
// make a callback to set state, but treating this as an exception
|
||||
const docExplorerRef = useRef(null);
|
||||
const mainSectionRef = useRef(null);
|
||||
const [schema, setSchema] = useState(null);
|
||||
const [showGqlDocs, setShowGqlDocs] = useState(false);
|
||||
const onSchemaLoad = (schema) => setSchema(schema);
|
||||
@@ -85,43 +90,72 @@ const RequestTabPanel = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const leftPaneWidth = (screenWidth - asideWidth) / 2.2;
|
||||
setLeftPaneWidth(leftPaneWidth);
|
||||
}, [screenWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
|
||||
}, [screenWidth, asideWidth, leftPaneWidth]);
|
||||
// Initialize vertical heights when switching to vertical layout
|
||||
if (mainSectionRef.current) {
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
if (isVerticalLayout) {
|
||||
const initialHeight = mainRect.height / 2;
|
||||
setTopPaneHeight(initialHeight);
|
||||
// In vertical mode, set leftPaneWidth to full container width
|
||||
setLeftPaneWidth(mainRect.width);
|
||||
} else {
|
||||
// In horizontal mode, set to roughly half width
|
||||
setLeftPaneWidth((screenWidth - asideWidth) / 2.2);
|
||||
}
|
||||
}
|
||||
}, [isVerticalLayout, screenWidth, asideWidth]);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (dragging) {
|
||||
if (dragging && mainSectionRef.current) {
|
||||
e.preventDefault();
|
||||
let leftPaneXPosition = e.clientX + 2;
|
||||
if (
|
||||
leftPaneXPosition < asideWidth + DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH ||
|
||||
leftPaneXPosition > screenWidth - MIN_RIGHT_PANE_WIDTH
|
||||
) {
|
||||
return;
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
|
||||
if (isVerticalLayout) {
|
||||
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
|
||||
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTopPaneHeight(newHeight);
|
||||
} else {
|
||||
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
|
||||
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
|
||||
return;
|
||||
}
|
||||
setLeftPaneWidth(newWidth);
|
||||
}
|
||||
setLeftPaneWidth(leftPaneXPosition - asideWidth);
|
||||
setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (dragging) {
|
||||
if (dragging && mainSectionRef.current) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
dispatch(
|
||||
updateRequestPaneTabWidth({
|
||||
uid: activeTabUid,
|
||||
requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING
|
||||
})
|
||||
);
|
||||
if (!isVerticalLayout) {
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
dispatch(
|
||||
updateRequestPaneTabWidth({
|
||||
uid: activeTabUid,
|
||||
requestPaneWidth: e.clientX - mainRect.left
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragbarMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
|
||||
if (isVerticalLayout) {
|
||||
const dragBar = e.currentTarget;
|
||||
const dragBarRect = dragBar.getBoundingClientRect();
|
||||
dragOffset.current.y = e.clientY - dragBarRect.top;
|
||||
} else {
|
||||
const dragBar = e.currentTarget;
|
||||
const dragBarRect = dragBar.getBoundingClientRect();
|
||||
dragOffset.current.x = e.clientX - dragBarRect.left;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,7 +166,7 @@ const RequestTabPanel = () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
};
|
||||
}, [dragging, asideWidth]);
|
||||
}, [dragging]);
|
||||
|
||||
if (!activeTabUid) {
|
||||
return <Welcome />;
|
||||
@@ -197,15 +231,19 @@ const RequestTabPanel = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''}`}>
|
||||
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
|
||||
<div className="pt-4 pb-3 px-4">
|
||||
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
|
||||
</div>
|
||||
<section className="main flex flex-grow pb-4 relative">
|
||||
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative`}>
|
||||
<section className="request-pane">
|
||||
<div
|
||||
className="px-4 h-full"
|
||||
style={{
|
||||
style={isVerticalLayout ? {
|
||||
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
|
||||
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
|
||||
width: '100%'
|
||||
} : {
|
||||
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
|
||||
}}
|
||||
>
|
||||
@@ -213,7 +251,6 @@ const RequestTabPanel = () => {
|
||||
<GraphQLRequestPane
|
||||
item={item}
|
||||
collection={collection}
|
||||
leftPaneWidth={leftPaneWidth}
|
||||
onSchemaLoad={onSchemaLoad}
|
||||
toggleDocs={toggleDocs}
|
||||
handleGqlClickReference={handleGqlClickReference}
|
||||
@@ -221,17 +258,17 @@ const RequestTabPanel = () => {
|
||||
) : null}
|
||||
|
||||
{item.type === 'http-request' ? (
|
||||
<HttpRequestPane item={item} collection={collection} leftPaneWidth={leftPaneWidth} />
|
||||
<HttpRequestPane item={item} collection={collection} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="drag-request" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="drag-request-border" />
|
||||
<div className="dragbar-wrapper" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
|
||||
<section className="response-pane flex-grow">
|
||||
<ResponsePane item={item} collection={collection} rightPaneWidth={rightPaneWidth} response={item.response} />
|
||||
<section className="response-pane flex-grow overflow-x-auto">
|
||||
<ResponsePane item={item} collection={collection} response={item.response} />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.warning-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-top: 10%;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
margin-bottom: 1rem;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warning-description {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
.size-highlight {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.current-size {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.text.danger}15;
|
||||
}
|
||||
|
||||
.supported-size {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
background: ${(props) => props.theme.colors.text.yellow}15;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: ${(props) => props.theme.button.secondary.bg};
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { IconDownload, IconCopy, IconEye, IconAlertTriangle } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { formatSize } from 'utils/common/index';
|
||||
|
||||
const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
|
||||
const { ipcRenderer } = window;
|
||||
const response = item.response || {};
|
||||
|
||||
const saveResponseToFile = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-response-to-file', response, item.requestSent.url)
|
||||
.then(() => {
|
||||
toast.success('Response saved to file');
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(get(err, 'error.message') || 'Something went wrong!');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const copyResponse = () => {
|
||||
try {
|
||||
const textToCopy = typeof response.data === 'string'
|
||||
? response.data
|
||||
: JSON.stringify(response.data, null, 2);
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
toast.success('Response copied to clipboard');
|
||||
}).catch(() => {
|
||||
toast.error('Failed to copy response');
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy response');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="warning-container">
|
||||
<div className="warning-icon">
|
||||
<IconAlertTriangle size={45} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="warning-content">
|
||||
<div className="warning-title">
|
||||
Large Response Warning
|
||||
</div>
|
||||
<div className="warning-description">
|
||||
Handling responses over <span className="size-highlight supported-size">{formatSize(10 * 1024 * 1024)}</span> could degrade performance.
|
||||
<br />
|
||||
Size of current response: <span className="size-highlight current-size">{formatSize(responseSize)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="warning-actions">
|
||||
<button
|
||||
className="btn-reveal"
|
||||
onClick={onRevealResponse}
|
||||
title="Show response content"
|
||||
>
|
||||
<IconEye size={18} strokeWidth={1.5} />
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
className="btn-save"
|
||||
onClick={saveResponseToFile}
|
||||
disabled={!response.dataBuffer}
|
||||
title="Save response to file"
|
||||
>
|
||||
<IconDownload size={18} strokeWidth={1.5} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="btn-copy"
|
||||
onClick={copyResponse}
|
||||
disabled={!response.data}
|
||||
title="Copy response to clipboard"
|
||||
>
|
||||
<IconCopy size={18} strokeWidth={1.5} />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default LargeResponseWarning;
|
||||
@@ -22,6 +22,15 @@ const StyledWrapper = styled.div`
|
||||
animation: rotateCounterClockwise 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// spinner and request time content looks better centered vertically in vertical layout
|
||||
// while in horizontal layout, it looks better when the content is aligned to the top
|
||||
&.vertical-layout {
|
||||
div.overlay {
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import React from 'react';
|
||||
import { IconRefresh } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StopWatch from '../../StopWatch';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseLoadingOverlay = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
|
||||
const handleCancelRequest = () => {
|
||||
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className={`w-full ${isVerticalLayout ? 'vertical-layout' : ''}`}>
|
||||
<div className="overlay">
|
||||
<div style={{ marginBottom: 15, fontSize: 26 }}>
|
||||
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>
|
||||
<StopWatch requestTimestamp={item?.requestSent?.timestamp} />
|
||||
<StopWatch startTime={item?.requestStartTime} />
|
||||
</div>
|
||||
</div>
|
||||
<IconRefresh size={24} className="loading-icon" />
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 20%;
|
||||
width: 100%;
|
||||
|
||||
.send-icon {
|
||||
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
|
||||
}
|
||||
|
||||
&.vertical-layout {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { IconSend } from '@tabler/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
@@ -8,9 +9,11 @@ const Placeholder = () => {
|
||||
const sendRequestShortcut = isMac ? 'Cmd + Enter' : 'Ctrl + Enter';
|
||||
const newRequestShortcut = isMac ? 'Cmd + B' : 'Ctrl + B';
|
||||
const editEnvironmentShortcut = isMac ? 'Cmd + E' : 'Ctrl + E';
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<StyledWrapper className={`${isVerticalLayout ? 'vertical-layout' : ''}`}>
|
||||
<div className="send-icon flex justify-center" style={{ fontSize: 200 }}>
|
||||
<IconSend size={150} strokeWidth={1} />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { getEncoding, uuid } from 'utils/common/index';
|
||||
import LargeResponseWarning from '../LargeResponseWarning';
|
||||
|
||||
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
|
||||
if (data === undefined || !dataBuffer || !mode) {
|
||||
@@ -73,10 +74,11 @@ const formatErrorMessage = (error) => {
|
||||
return error;
|
||||
};
|
||||
|
||||
const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
|
||||
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
|
||||
const contentType = getContentType(headers);
|
||||
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
|
||||
const [filter, setFilter] = useState(null);
|
||||
const [showLargeResponse, setShowLargeResponse] = useState(false);
|
||||
const responseEncoding = getEncoding(headers);
|
||||
const formattedData = useMemo(
|
||||
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
|
||||
@@ -84,6 +86,25 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
);
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
const responseSize = useMemo(() => {
|
||||
const response = item.response || {};
|
||||
if (typeof response.size === 'number') {
|
||||
return response.size;
|
||||
}
|
||||
|
||||
if (!dataBuffer) return 0;
|
||||
|
||||
try {
|
||||
// dataBuffer is base64 encoded, so we need to calculate the actual size
|
||||
const buffer = Buffer.from(dataBuffer, 'base64');
|
||||
return buffer.length;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}, [dataBuffer, item.response]);
|
||||
|
||||
const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const debouncedResultFilterOnChange = debounce((e) => {
|
||||
setFilter(e.target.value);
|
||||
}, 250);
|
||||
@@ -143,7 +164,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="w-full h-full relative flex"
|
||||
style={{ maxWidth: width }}
|
||||
queryFilterEnabled={queryFilterEnabled}
|
||||
>
|
||||
<div className="flex justify-end gap-2 text-xs" role="tablist">
|
||||
@@ -151,7 +171,9 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
</div>
|
||||
{error ? (
|
||||
<div>
|
||||
{hasScriptError ? null : <div className="text-red-500">{formatErrorMessage(error)}</div>}
|
||||
{hasScriptError ? null : (
|
||||
<div className="text-red-500" style={{ whiteSpace: 'pre-line' }}>{formatErrorMessage(error)}</div>
|
||||
)}
|
||||
|
||||
{error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
|
||||
<div className="mt-6 muted text-xs">
|
||||
@@ -160,6 +182,12 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : isLargeResponse && !showLargeResponse ? (
|
||||
<LargeResponseWarning
|
||||
item={item}
|
||||
responseSize={responseSize}
|
||||
onRevealResponse={() => setShowLargeResponse(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 relative">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IconDockToBottom = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
<path stroke="none" fill="none" d="M0 0h24v24H0z" />
|
||||
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
|
||||
<path d="M4 15l16 0" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M 5.5135136,19.111502 C 5.2542477,18.995986 5.0221761,18.756859 4.8928709,18.47199 4.7922381,18.250288 4.7788524,18.078909 4.7777079,16.997543 l -0.0013,-1.223586 H 12 19.223587 v 1.22675 c 0,1.194609 -0.0039,1.234605 -0.149369,1.526503 -0.09333,0.187285 -0.240773,0.363095 -0.392978,0.46858 l -0.243606,0.168829 -6.373606,0.0129 c -5.2129418,0.0105 -6.4058225,-0.0015 -6.5505114,-0.06597 z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const IconDockToRight = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
<path fill="none" stroke="none" d="M 0,24 V 0 h 24 v 24 z" />
|
||||
<path d="m 4,20 m 2,0 A 2,2 0 0 1 4,18 V 6 A 2,2 0 0 1 6,4 h 12 a 2,2 0 0 1 2,2 v 12 a 2,2 0 0 1 -2,2 z" />
|
||||
<path d="M 15,20 V 4" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
d="m 19.111502,18.486486 c -0.115516,0.259266 -0.354643,0.491338 -0.639512,0.620643 -0.221702,0.100633 -0.393081,0.114019 -1.474447,0.115163 l -1.223586,0.0013 V 12 4.7764125 h 1.22675 c 1.194609,0 1.234605,0.0039 1.526503,0.14937 0.187285,0.09333 0.363095,0.2407725 0.46858,0.3929775 l 0.168829,0.243606 0.0129,6.373606 c 0.0105,5.212942 -0.0015,6.405822 -0.06597,6.550511 z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponseLayoutToggle = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
|
||||
const toggleOrientation = () => {
|
||||
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences.layout,
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={toggleOrientation}
|
||||
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconDockToBottom />
|
||||
) : (
|
||||
<IconDockToRight />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseLayoutToggle;
|
||||
@@ -0,0 +1,173 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent} from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ThemeProvider } from 'providers/Theme';
|
||||
import { configureStore, createSlice } from '@reduxjs/toolkit';
|
||||
import ResponseLayoutToggle from './index';
|
||||
|
||||
const mockSavePreferences = jest.fn((payload) => ({ type: 'app/savePreferences', payload }));
|
||||
|
||||
// Mock the savePreferences action
|
||||
jest.mock('providers/ReduxStore/slices/app', () => ({
|
||||
savePreferences: (payload) => mockSavePreferences(payload)
|
||||
}));
|
||||
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = {
|
||||
getItem: jest.fn(() => 'dark'),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn()
|
||||
};
|
||||
|
||||
// Mock matchMedia
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
})),
|
||||
});
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSavePreferences.mockClear();
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
app: {
|
||||
preferences: {
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createTestStore = (initialState) => {
|
||||
const appSlice = createSlice({
|
||||
name: 'app',
|
||||
initialState: initialState.app,
|
||||
reducers: {
|
||||
savePreferences: (state, action) => {
|
||||
state.preferences = action.payload;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return configureStore({
|
||||
reducer: { app: appSlice.reducer }
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component, customState = initialState) => {
|
||||
const store = createTestStore(customState);
|
||||
return {
|
||||
store,
|
||||
...render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
describe('ResponseLayoutToggle', () => {
|
||||
describe('Initial Render', () => {
|
||||
it('should render with horizontal orientation by default', () => {
|
||||
renderWithProviders(<ResponseLayoutToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
});
|
||||
|
||||
it('should render with vertical orientation when specified', () => {
|
||||
const customState = {
|
||||
app: {
|
||||
preferences: {
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
renderWithProviders(<ResponseLayoutToggle />, customState);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('should switch to vertical layout when clicked in horizontal mode', () => {
|
||||
const { store } = renderWithProviders(<ResponseLayoutToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Initial state check
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
// Check if action was called
|
||||
expect(mockSavePreferences).toHaveBeenCalledWith({
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update store to simulate state change
|
||||
store.dispatch(mockSavePreferences({
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
}));
|
||||
|
||||
// Check if button title was updated
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
});
|
||||
|
||||
it('should switch to horizontal layout when clicked in vertical mode', () => {
|
||||
const customState = {
|
||||
app: {
|
||||
preferences: {
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const { store } = renderWithProviders(<ResponseLayoutToggle />, customState);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Initial state check
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
// Check if action was called
|
||||
expect(mockSavePreferences).toHaveBeenCalledWith({
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update store to simulate state change
|
||||
store.dispatch(mockSavePreferences({
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
}
|
||||
}));
|
||||
|
||||
// Check if button title was updated
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import ResponseSize from './index';
|
||||
|
||||
// Create minimal theme with only the properties needed for the component
|
||||
const theme = {
|
||||
requestTabPanel: {
|
||||
responseStatus: '#666'
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap component with theme provider for styled-components
|
||||
const renderWithTheme = (component) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ResponseSize', () => {
|
||||
describe('Invalid or excluded size values', () => {
|
||||
it('should not render when size is undefined', () => {
|
||||
const { container } = renderWithTheme(<ResponseSize size={undefined} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when size is null', () => {
|
||||
const { container } = renderWithTheme(<ResponseSize size={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when size is NaN', () => {
|
||||
const { container } = renderWithTheme(<ResponseSize size={NaN} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when size is Infinity', () => {
|
||||
const { container } = renderWithTheme(<ResponseSize size={Infinity} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when size is -Infinity', () => {
|
||||
const { container } = renderWithTheme(<ResponseSize size={-Infinity} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when size is a string', () => {
|
||||
const { container } = renderWithTheme(<ResponseSize size="1024" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when size is an object', () => {
|
||||
const { container } = renderWithTheme(<ResponseSize size={{value: 1024}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Valid size values', () => {
|
||||
it('should handle zero bytes', () => {
|
||||
renderWithTheme(<ResponseSize size={0} />);
|
||||
const element = screen.getByText(/0B/);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.textContent).toMatch(/^0B$/);
|
||||
expect(element).toHaveAttribute('title', '0B');
|
||||
});
|
||||
|
||||
it('should render bytes when size is less than 1024', () => {
|
||||
renderWithTheme(<ResponseSize size={500} />);
|
||||
const element = screen.getByText(/500B/);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.textContent).toMatch(/^500B$/);
|
||||
expect(element).toHaveAttribute('title', '500B');
|
||||
});
|
||||
|
||||
it('should handle exactly 1024 bytes as size', () => {
|
||||
renderWithTheme(<ResponseSize size={1024} />);
|
||||
const element = screen.getByText(/1024B/);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.textContent).toMatch(/^1024B$/);
|
||||
expect(element).toHaveAttribute('title', '1,024B');
|
||||
});
|
||||
|
||||
it('should render kilobytes when size is greater than 1024', () => {
|
||||
renderWithTheme(<ResponseSize size={1500} />);
|
||||
const element = screen.getByText(/1\.46KB/);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
|
||||
expect(element).toHaveAttribute('title', '1,500B');
|
||||
});
|
||||
|
||||
it('should handle large size numbers', () => {
|
||||
renderWithTheme(<ResponseSize size={10240} />);
|
||||
const element = screen.getByText(/10\.0KB/);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
|
||||
expect(element).toHaveAttribute('title', '10,240B');
|
||||
});
|
||||
|
||||
it('should handle decimal size numbers', () => {
|
||||
renderWithTheme(<ResponseSize size={1126.5} />);
|
||||
const element = screen.getByText(/1\.10KB/);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
|
||||
expect(element).toHaveAttribute('title', '1,126.5B');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,109 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.timeline-event {
|
||||
padding: 8px 0 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-event-content {
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-event-header {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.method-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.url-text {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.oauth-section {
|
||||
.oauth-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 600;
|
||||
|
||||
span {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-switcher {
|
||||
border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
margin-bottom: 16px;
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.tabs.active.border};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.network-logs {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.oauth-request-item-content {
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.collapsible-section {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.section-header {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
white-space: pre-line;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
font-family: Inter, sans-serif !important;
|
||||
font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
|
||||
|
||||
.arrow {
|
||||
opacity: 0.5;
|
||||
@@ -19,6 +117,35 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.purple};
|
||||
}
|
||||
}
|
||||
|
||||
.request-label {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-left: 8px;
|
||||
background: ${(props) => props.theme.requestTabs.bg};
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-weight: 600;
|
||||
table-layout: fixed;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: 0.8125rem;
|
||||
user-select: none;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import forOwn from 'lodash/forOwn';
|
||||
import { safeStringifyJSON } from 'utils/common';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import TimelineItem from '../Timeline/TimelineItem';
|
||||
|
||||
const RunnerTimeline = ({ request, response }) => {
|
||||
const RunnerTimeline = ({ request = {}, response = {}, item, collection }) => {
|
||||
const requestHeaders = [];
|
||||
const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
|
||||
|
||||
request = request || {};
|
||||
response = response || {};
|
||||
|
||||
forOwn(request.headers, (value, key) => {
|
||||
requestHeaders.push({
|
||||
@@ -17,43 +13,56 @@ const RunnerTimeline = ({ request, response }) => {
|
||||
});
|
||||
});
|
||||
|
||||
let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
|
||||
const oauth2Events = useMemo(
|
||||
() =>
|
||||
collection?.timeline?.filter(
|
||||
(event) => event.type === 'oauth2' && event.itemUid === item.uid
|
||||
) || [],
|
||||
[collection?.timeline, item.uid]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="pb-4 w-full">
|
||||
<div>
|
||||
<pre className="line request font-bold">
|
||||
<span className="arrow">{'>'}</span> {request.method} {request.url}
|
||||
</pre>
|
||||
{requestHeaders.map((h) => {
|
||||
return (
|
||||
<pre className="line request" key={h.name}>
|
||||
<span className="arrow">{'>'}</span> {h.name}: {h.value}
|
||||
</pre>
|
||||
);
|
||||
})}
|
||||
|
||||
{requestData ? (
|
||||
<pre className="line request">
|
||||
<span className="arrow">{'>'}</span> data{' '}
|
||||
<pre className="text-sm flex flex-wrap whitespace-break-spaces">{requestData}</pre>
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<pre className="line response font-bold">
|
||||
<span className="arrow">{'<'}</span> {response.status} - {response.statusText}
|
||||
</pre>
|
||||
|
||||
{responseHeaders.map((h) => {
|
||||
return (
|
||||
<pre className="line response" key={h[0]}>
|
||||
<span className="arrow">{'<'}</span> {h[0]}: {h[1]}
|
||||
</pre>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Show the main request/response timeline item */}
|
||||
<TimelineItem
|
||||
request={request}
|
||||
response={response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
hideTimestamp={true}
|
||||
/>
|
||||
|
||||
{oauth2Events.map((event, index) => {
|
||||
const { data, timestamp } = event;
|
||||
const { debugInfo } = data;
|
||||
return (
|
||||
<div key={`oauth2-${index}`} className="timeline-event mt-4">
|
||||
<div className="timeline-event-header cursor-pointer flex items-center">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold">OAuth2.0 Calls</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{debugInfo && debugInfo.length > 0 ? (
|
||||
debugInfo.map((data, idx) => (
|
||||
<div key={idx} className='ml-4'>
|
||||
<TimelineItem
|
||||
timestamp={timestamp}
|
||||
request={data?.request}
|
||||
response={data?.response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
isOauth2={true}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div>No debug information available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,22 +6,48 @@ import StyledWrapper from './StyledWrapper';
|
||||
const ScriptError = ({ item, onClose }) => {
|
||||
const preRequestError = item?.preRequestScriptErrorMessage;
|
||||
const postResponseError = item?.postResponseScriptErrorMessage;
|
||||
const testScriptError = item?.testScriptErrorMessage;
|
||||
|
||||
if (!preRequestError && !postResponseError) return null;
|
||||
if (!preRequestError && !postResponseError && !testScriptError) return null;
|
||||
|
||||
const errorMessage = preRequestError || postResponseError;
|
||||
const errorTitle = preRequestError ? 'Pre-Request Script Error' : 'Post-Response Script Error';
|
||||
const errors = [];
|
||||
|
||||
if (preRequestError) {
|
||||
errors.push({
|
||||
title: 'Pre-Request Script Error',
|
||||
message: preRequestError
|
||||
});
|
||||
}
|
||||
|
||||
if (postResponseError) {
|
||||
errors.push({
|
||||
title: 'Post-Response Script Error',
|
||||
message: postResponseError
|
||||
});
|
||||
}
|
||||
|
||||
if (testScriptError) {
|
||||
errors.push({
|
||||
title: 'Test Script Error',
|
||||
message: testScriptError
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-4 mb-2">
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="error-title">
|
||||
{errorTitle}
|
||||
</div>
|
||||
<div className="error-message">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{errors.map((error, index) => (
|
||||
<div key={index}>
|
||||
{index > 0 && <div className="border-t border-gray-300 my-3 dark:border-gray-600"></div>}
|
||||
<div className="error-title">
|
||||
{error.title}
|
||||
</div>
|
||||
<div className="error-message">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="close-button flex-shrink-0 cursor-pointer"
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.test-summary {
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.test-success {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
@@ -9,12 +21,24 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.test-success-count {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
.test-failure-count {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.skipped-request {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
.test-results-list {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,63 +1,151 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCircleCheck,
|
||||
IconCircleX
|
||||
} from '@tabler/icons';
|
||||
|
||||
const TestResults = ({ results, assertionResults }) => {
|
||||
const ResultIcon = ({ status }) => (
|
||||
<span className={`inline-flex items-center ${status === 'pass' ? 'test-success' : 'test-failure'}`}>
|
||||
{status === 'pass' ? (
|
||||
<IconCircleCheck size={14} className="mr-1" aria-label="Test passed" />
|
||||
) : (
|
||||
<IconCircleX size={14} className="mr-1" aria-label="Test failed" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ErrorMessage = ({ error }) => error && (
|
||||
<>
|
||||
<br />
|
||||
<span className="error-message pl-8" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const ResultItem = ({ result, type }) => (
|
||||
<div className="test-result-item">
|
||||
<ResultIcon status={result.status} />
|
||||
<span className={result.status === 'pass' ? 'test-success' : 'test-failure'}>
|
||||
{type === 'assertion'
|
||||
? `${result.lhsExpr}: ${result.rhsExpr}`
|
||||
: result.description
|
||||
}
|
||||
</span>
|
||||
<ErrorMessage error={result.error} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const TestSection = ({
|
||||
title,
|
||||
results,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
type = 'test'
|
||||
}) => {
|
||||
const passedResults = results.filter((result) => result.status === 'pass');
|
||||
const failedResults = results.filter((result) => result.status === 'fail');
|
||||
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<div
|
||||
className="font-medium test-summary flex items-center cursor-pointer hover:bg-opacity-10 hover:bg-gray-500 rounded py-2"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="dropdown-icon mr-2 flex items-center">
|
||||
{isExpanded ?
|
||||
<IconChevronDown size={18} stroke={1.5} /> :
|
||||
<IconChevronRight size={18} stroke={1.5} />
|
||||
}
|
||||
</span>
|
||||
<span className="flex-grow">
|
||||
{title} ({results.length}), Passed: {passedResults.length}, Failed: {failedResults.length}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<ul className="ml-5">
|
||||
{results.map((result) => (
|
||||
<li key={result.uid} className="py-1">
|
||||
<ResultItem result={result} type={type} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
|
||||
results = results || [];
|
||||
assertionResults = assertionResults || [];
|
||||
if (!results.length && !assertionResults.length) {
|
||||
return <div className="px-3">No tests found</div>;
|
||||
preRequestTestResults = preRequestTestResults || [];
|
||||
postResponseTestResults = postResponseTestResults || [];
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
preRequest: true,
|
||||
tests: true,
|
||||
postResponse: true,
|
||||
assertions: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedSections({
|
||||
preRequest: preRequestTestResults.length > 0,
|
||||
tests: results.length > 0,
|
||||
postResponse: postResponseTestResults.length > 0,
|
||||
assertions: assertionResults.length > 0
|
||||
});
|
||||
}, [results.length, assertionResults.length, preRequestTestResults.length, postResponseTestResults.length]);
|
||||
|
||||
const toggleSection = (section) => {
|
||||
setExpandedSections({
|
||||
...expandedSections,
|
||||
[section]: !expandedSections[section]
|
||||
});
|
||||
};
|
||||
|
||||
if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
|
||||
return <div>No tests found</div>;
|
||||
}
|
||||
|
||||
const passedTests = results.filter((result) => result.status === 'pass');
|
||||
const failedTests = results.filter((result) => result.status === 'fail');
|
||||
|
||||
const passedAssertions = assertionResults.filter((result) => result.status === 'pass');
|
||||
const failedAssertions = assertionResults.filter((result) => result.status === 'fail');
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col">
|
||||
<div className="pb-2 font-medium test-summary">
|
||||
Tests ({results.length}/{results.length}), Passed: {passedTests.length}, Failed: {failedTests.length}
|
||||
</div>
|
||||
<ul className="">
|
||||
{results.map((result) => (
|
||||
<li key={result.uid} className="py-1">
|
||||
{result.status === 'pass' ? (
|
||||
<span className="test-success">✔ {result.description}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="test-failure">✘ {result.description}</span>
|
||||
<br />
|
||||
<span className="error-message pl-8">{result.error}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<TestSection
|
||||
title="Pre-Request Tests"
|
||||
results={preRequestTestResults}
|
||||
isExpanded={expandedSections.preRequest}
|
||||
onToggle={() => toggleSection('preRequest')}
|
||||
type="test"
|
||||
/>
|
||||
|
||||
<div className="py-2 font-medium test-summary">
|
||||
Assertions ({assertionResults.length}/{assertionResults.length}), Passed: {passedAssertions.length}, Failed:{' '}
|
||||
{failedAssertions.length}
|
||||
</div>
|
||||
<ul className="">
|
||||
{assertionResults.map((result) => (
|
||||
<li key={result.uid} className="py-1">
|
||||
{result.status === 'pass' ? (
|
||||
<span className="test-success">
|
||||
✔ {result.lhsExpr}: {result.rhsExpr}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="test-failure">
|
||||
✘ {result.lhsExpr}: {result.rhsExpr}
|
||||
</span>
|
||||
<br />
|
||||
<span className="error-message pl-8">{result.error}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<TestSection
|
||||
title="Post-Response Tests"
|
||||
results={postResponseTestResults}
|
||||
isExpanded={expandedSections.postResponse}
|
||||
onToggle={() => toggleSection('postResponse')}
|
||||
type="test"
|
||||
/>
|
||||
|
||||
<TestSection
|
||||
title="Tests"
|
||||
results={results}
|
||||
isExpanded={expandedSections.tests}
|
||||
onToggle={() => toggleSection('tests')}
|
||||
type="test"
|
||||
/>
|
||||
|
||||
<TestSection
|
||||
title="Assertions"
|
||||
results={assertionResults}
|
||||
isExpanded={expandedSections.assertions}
|
||||
onToggle={() => toggleSection('assertions')}
|
||||
type="assertion"
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { IconCircleCheck, IconCircleX } from '@tabler/icons';
|
||||
|
||||
const TestResultsLabel = ({ results, assertionResults }) => {
|
||||
const TestResultsLabel = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
|
||||
results = results || [];
|
||||
assertionResults = assertionResults || [];
|
||||
if (!results.length && !assertionResults.length) {
|
||||
preRequestTestResults = preRequestTestResults || [];
|
||||
postResponseTestResults = postResponseTestResults || [];
|
||||
|
||||
if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
|
||||
return 'Tests';
|
||||
}
|
||||
|
||||
@@ -13,8 +17,14 @@ const TestResultsLabel = ({ results, assertionResults }) => {
|
||||
const numberOfAssertions = assertionResults.length;
|
||||
const numberOfFailedAssertions = assertionResults.filter((result) => result.status === 'fail').length;
|
||||
|
||||
const totalNumberOfTests = numberOfTests + numberOfAssertions;
|
||||
const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions;
|
||||
const numberOfPreRequestTests = preRequestTestResults.length;
|
||||
const numberOfFailedPreRequestTests = preRequestTestResults.filter((result) => result.status === 'fail').length;
|
||||
|
||||
const numberOfPostResponseTests = postResponseTestResults.length;
|
||||
const numberOfFailedPostResponseTests = postResponseTestResults.filter((result) => result.status === 'fail').length;
|
||||
|
||||
const totalNumberOfTests = numberOfTests + numberOfAssertions + numberOfPreRequestTests + numberOfPostResponseTests;
|
||||
const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions + numberOfFailedPreRequestTests + numberOfFailedPostResponseTests;
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import QueryResult from "components/ResponsePane/QueryResult/index";
|
||||
import { useState } from "react";
|
||||
|
||||
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }) => {
|
||||
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
|
||||
const [isBodyCollapsed, toggleBody] = useState(true);
|
||||
return (
|
||||
<div className="collapsible-section">
|
||||
@@ -17,7 +17,6 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }
|
||||
<QueryResult
|
||||
item={item}
|
||||
collection={collection}
|
||||
width={width}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
headers={headers}
|
||||
|
||||
@@ -16,7 +16,7 @@ const safeStringifyJSONIfNotString = (obj) => {
|
||||
};
|
||||
|
||||
|
||||
const Request = ({ collection, request, item, width }) => {
|
||||
const Request = ({ collection, request, item }) => {
|
||||
let { url, headers, data, dataBuffer, error } = request || {};
|
||||
if (!dataBuffer) {
|
||||
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
|
||||
@@ -33,7 +33,7 @@ const Request = ({ collection, request, item, width }) => {
|
||||
<Headers headers={headers} type={'request'} />
|
||||
|
||||
{/* Body */}
|
||||
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
|
||||
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const safeStringifyJSONIfNotString = (obj) => {
|
||||
}
|
||||
};
|
||||
|
||||
const Response = ({ collection, response, item, width }) => {
|
||||
const Response = ({ collection, response, item }) => {
|
||||
let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {};
|
||||
if (!dataBuffer) {
|
||||
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
|
||||
@@ -35,7 +35,7 @@ const Response = ({ collection, response, item, width }) => {
|
||||
<Headers headers={headers} type={'response'} />
|
||||
|
||||
{/* Body */}
|
||||
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
|
||||
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Method from "./Common/Method/index";
|
||||
import Status from "./Common/Status/index";
|
||||
import { RelativeTime } from "./Common/Time/index";
|
||||
|
||||
const TimelineItem = ({ timestamp, request, response, item, collection, width, isOauth2 }) => {
|
||||
const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2, hideTimestamp = false }) => {
|
||||
const [isCollapsed, _toggleCollapse] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('request');
|
||||
const toggleCollapse = () => _toggleCollapse(prev => !prev);
|
||||
@@ -23,11 +23,15 @@ const TimelineItem = ({ timestamp, request, response, item, collection, width, i
|
||||
<Method method={method} />
|
||||
<Status statusCode={status || statusCode} statusText={statusText} />
|
||||
{isOauth2 ? <pre className="opacity-50">[oauth2.0]</pre> : null}
|
||||
<pre className="opacity-70">[{new Date(timestamp).toISOString()}]</pre>
|
||||
{!hideTimestamp && (
|
||||
<>
|
||||
<pre className="opacity-70">[{new Date(timestamp).toISOString()}]</pre>
|
||||
<span className="text-sm text-gray-400 flex-shrink-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<RelativeTime timestamp={timestamp} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 flex-shrink-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<RelativeTime timestamp={timestamp} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-sm mt-1">{url}</div>
|
||||
</div>
|
||||
@@ -57,15 +61,15 @@ const TimelineItem = ({ timestamp, request, response, item, collection, width, i
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="tab-content">
|
||||
<div className="tab-content break-all">
|
||||
{/* Request Tab */}
|
||||
{activeTab === 'request' && (
|
||||
<Request request={request} item={item} collection={collection} width={width} />
|
||||
<Request request={request} item={item} collection={collection} />
|
||||
)}
|
||||
|
||||
{/* Response Tab */}
|
||||
{activeTab === 'response' && (
|
||||
<Response response={response} item={item} collection={collection} width={width} />
|
||||
<Response response={response} item={item} collection={collection} />
|
||||
)}
|
||||
|
||||
{/* Network Logs Tab */}
|
||||
|
||||
@@ -41,7 +41,7 @@ const getEffectiveAuthSource = (collection, item) => {
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const Timeline = ({ collection, item, width }) => {
|
||||
const Timeline = ({ collection, item }) => {
|
||||
// Get the effective auth source if auth mode is inherit
|
||||
const authSource = getEffectiveAuthSource(collection, item);
|
||||
|
||||
@@ -62,7 +62,6 @@ const Timeline = ({ collection, item, width }) => {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="pb-4 w-full flex flex-grow flex-col"
|
||||
style={{ maxWidth: width - 60, overflowWrap: 'break-word' }}
|
||||
>
|
||||
{combinedTimeline.map((event, index) => {
|
||||
if (event.type === 'request') {
|
||||
@@ -76,7 +75,6 @@ const Timeline = ({ collection, item, width }) => {
|
||||
response={response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -101,7 +99,6 @@ const Timeline = ({ collection, item, width }) => {
|
||||
response={data?.response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
width={width - 50}
|
||||
isOauth2={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user