Compare commits

...

61 Commits

Author SHA1 Message Date
naman-bruno
90a4f8271e fix 2025-12-12 01:00:55 +05:30
naman-bruno
0c62ead394 fixes 2025-12-12 00:21:56 +05:30
naman-bruno
b82b3088c1 fixes 2025-12-11 23:21:25 +05:30
naman-bruno
72dccdb8e3 fix 2025-12-11 22:50:32 +05:30
naman-bruno
013c128c78 fixes 2025-12-11 19:53:02 +05:30
naman-bruno
9053c47789 add: tests 2025-12-11 19:53:02 +05:30
naman-bruno
f3fd8a1ce0 fix: default workspace error checking 2025-12-11 19:53:02 +05:30
naman-bruno
575f37124c fixes (#6383) 2025-12-11 19:49:27 +05:30
sanish chirayath
50a72a16bc fix: tag persistence tests (#6384) 2025-12-11 19:46:36 +05:30
sanish chirayath
98513c65f0 fix: gRPC oauth2 call is not taking ssl cert and proxy config (#6313)
* fix: grpc oauth2

* fix: review comments

* fix: review comments
2025-12-11 16:06:19 +05:30
sanish chirayath
b61d2212f6 fix: Consistent multipart form handling and @contentType support in examples (#6325)
* fix: multiline multipart items within multipart within response example

* change multiline  editor to single line fot contentType

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-11 01:17:39 +05:30
naman-bruno
1ed957978a Improve tables design (#6330) 2025-12-10 19:39:16 +05:30
naman-bruno
c00cbf6cb2 workspace schema update (#6374) 2025-12-10 19:38:47 +05:30
Abhishek S Lal
632f8705e5 feat: implement sidebar accordion sections (#6373)
* feat: implement sidebar accordion sections

- Added SidebarAccordionContext for managing expanded sections.
- Introduced SidebarContent component to render sections dynamically.
- Created CollectionsSection and ApiSpecsSection for sidebar organization.
- Updated Sidebar component to utilize new sections and context.
- Enhanced StyledWrapper for improved layout and styling of sidebar sections.

* refactor: streamline Sidebar component and enhance styling

* feat: enhance SidebarSection with ActionIcon and improved hover styles

* fix: update useEffect dependencies in SidebarAccordionContext and enhance accessibility in SidebarSection

* style: increase gap in StyledWrapper and reintroduce cursor pointer for better user interaction

* style: remove custom scrollbar styles from Sidebar components for a cleaner look
2025-12-10 19:04:46 +05:30
naman-bruno
f8548225e1 fixes (#6372) 2025-12-10 17:43:35 +05:30
Anoop M D
7fe6b47aa0 chore: updated request tab padding (#6368)
* chore: updated request tab padding
2025-12-10 04:30:58 +05:30
naman-bruno
43f24ad0f1 redesign: workspace overview (#6361)
* redesign: workspace overview

* fixes

* fix: test
2025-12-10 02:56:28 +05:30
Abhishek S Lal
a798b32f25 feat: add response data type selector in response viewer (#6100)
* feat: add response data type selector in response viewer

* chore: fixed lint issue

* test: add test for resonse format change and preview.

* refactor: streamline response format tests with utility functions for navigation and format switching

* refactor: simplify ButtonDropdown component and enhance QueryResultTypeSelector with header and toggle switch

* feat: enhance ButtonDropdown with prefix and suffix props; implement content type detection and update QueryResult for improved format handling

* fix: lint errors resolved

* fix: remove unnecessary blank line to resolve lint issues

* fix: update response format tests

* refactor: remove preview tab locator from response format tests

* fix: update dependency in useEffect to include previewFormatOptions for accurate format handling

* refactor: reorganize imports and enhance QueryResult component for improved format handling and error display

* fix: update error messages in response format preview tests and adjust version in JSON fixture

* feat: add drag detection to HtmlPreview component and update structure for improved user interaction

* refactor: update ResponsePane components for improved structure and functionality;

replace QueryResult with QueryResponse, enhance layout handling, and streamline response actions

* refactor: remove ButtonDropdown component and associated styles;

* refactor: moved ErrorAlert to ui folder

* fix: lint error

* feat: add data-testid attributes to Collection and CollectionItem components for improved testability

* feat: hide dropdown on select in response selector

* fix: update QueryResult component to use detectedContentType for format handling

* test: update ResponseLayoutToggle tests to use data-testid for button selection

* feat: add data-testid attribute to ResponseClear component for improved testability

* refactor: implement clickResponseAction utility for streamlined response action handling in tests

* feat: add data-testid attribute to ResponseCopy component for enhanced testability

* fix: unwanted code in test
2025-12-09 23:45:01 +05:30
Bijin A B
4d1c3f9e52 chore: reduce ux conflicts with toasts in playwright (#6367) 2025-12-09 23:11:16 +05:30
Abhishek S Lal
879d2271b7 fix: update default state for advanced options and change default collection format (#6366) 2025-12-09 22:54:56 +05:30
naman-bruno
cf4c896431 improve: tabs design (#6363)
* improve: tabs design

* fixes: tests
2025-12-09 21:35:27 +05:30
Chirag Chandrashekhar
f6363389d0 Prototype/simplify request creation (#6295)
* feat: add dropdown for quick request creation in tab bar

- Create reusable CreateUntitledRequest component with customizable trigger
- Add generateUniqueRequestName utility for unique request naming
- Replace modal-based request creation with dropdown in tab bar
- Support HTTP, GraphQL, WebSocket, and gRPC request types
- Generate unique names (Untitled, Untitled1, etc.) automatically
- Create requests at collection root level

* Update request creation and collection components

* Fix dropdown positioning and styling when appended to document.body

- Change appendTo from 'parent' to document.body for absolute positioning
- Add comprehensive styling via onShow handler to ensure proper width, padding, text color, and opacity
- Add global styles as fallback for dropdown elements
- Ensure dropdown overlaps parent without expanding it

* Update RequestTabs and Collection components

* Add curl paste detection and parsing for HTTP requests

* Fix generateUniqueRequestName to check filesystem for existing files

* feat: add placeholder text to HTTP request URL input

Add helpful placeholder text 'Enter URL or paste a cURL request' to the HTTP request URL input field. This guides users on how to use the input field, indicating they can either enter a URL directly or paste a cURL command which will be automatically parsed.

* Simplify request creation in collection menu

* fix: fixed issues with cURL paste for GraphQL requests in the URL input bar

* fix: added icons to create request dropdown

* fix: fixed the icon | text gap in dropdown

* fix: removed unnecessary updates on the Dropdown Component

* added onCreate to Dropdown to remove unwanted diffs

* fix: simplified the generateUniqueRequestName function. ai writes complex code

* chore: formatting and removed unnecessary diffs

* Update packages/bruno-app/src/components/RequestPane/QueryUrl/index.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: format

* Fix failing E2E tests by updating to new request creation flow

- Replace #create-new-tab selector with new dropdown flow using createUntitledRequest helper
- Update generateUniqueRequestName to handle .bru, .yml, and .yaml file extensions
- Add createUntitledRequest helper function with optional URL and tag parameters
- Update all failing tests to use the new helper function
- Fix selectors from .collection-item-name to .item-name where needed
- All 13 previously failing tests now pass

* chore: removed unused import

---------

Co-authored-by: Sid <siddharth@usebruno.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-09 19:08:52 +05:30
Anoop M D
03e8f2d67d Merge pull request #6303 from bpacholek/feature/windows-on-arm64-support
Feature: Enabled ARM64 build for Windows.
2025-12-09 17:14:17 +05:30
dependabot[bot]
8e855e53bf chore(deps): bump body-parser from 1.20.3 to 2.2.0 (#4383)
* chore(deps): bump body-parser from 1.20.3 to 2.2.0

Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.3 to 2.2.0.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.3...v2.2.0)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: parse raw body for content types not already handled by other parsers

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-09 12:19:06 +05:30
james-ha-bruno
599636d56b update for duplicative entry (#6356) 2025-12-09 12:17:10 +05:30
Anoop M D
9b9534c1eb feat: toolbar design updates (#6354)
* feat: toolbar design updates

* chore: addressed coderabbit review comments

* fix: update unit tests

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-09 12:15:18 +05:30
Abhishek S Lal
0197ae37c8 refactor: update AppTitleBar and SidebarHeader components (#6341)
* refactor: update AppTitleBar and SidebarHeader components to use MenuDropdown and ActionIcon for improved UI consistency

* refactor: update button locators in tests to use data-testid for consistency and improved readability
2025-12-08 22:06:24 +05:30
Dániel Seres
cf969dfcd6 fix: Support @contentType for multiline values (#6217)
* fix: Support @contentType for multiline values

Fixes the issue where the @contentType annotation broke the parsing of multiline values.

* chore: add dotall flag to fileExtractContentType

Not strictly needed since body:file uses single-line values in practice,
but doesn't hurt and matches what multipartExtractContentType does.

---------

Co-authored-by: Márk Dániel Seres <markdaniel.seres@tesco.com>
2025-12-08 18:39:25 +05:30
Pragadesh-45
a66be21523 feat: Enhance runCollectionFolder to support selected request ordering (#6320)
* Refactor `runCollectionFolder` action to accept `selectedRequestUids` for filtering and ordering requests.
* Update IPC handler to process `selectedRequestUids`, ensuring requests are executed in the specified order while preserving folder data.
2025-12-08 17:16:24 +05:30
naman-bruno
4016754d71 feat: integrate import/export modals and refactor environment handling (#6346) 2025-12-08 15:17:53 +05:30
Anoop M D
f3aebf6374 Merge pull request #6345 from usebruno/feat/design-updates
feat: design updates
2025-12-08 14:42:06 +05:30
naman-bruno
f87460b00e refactor: simplify last opened workspaces management by removing workspace config from storage and improving path handling (#6343) 2025-12-08 14:30:16 +05:30
naman-bruno
354e8d7496 feat: add hideApiSpecPage dispatch to Collection and CollectionItem components (#6344) 2025-12-08 14:24:47 +05:30
Anoop M D
dc107f8b96 init (#6337) 2025-12-08 01:37:16 +05:30
naman-bruno
cd0f1e45ba init 2025-12-07 21:53:47 +05:30
Bijin A B
33022843f2 fix: CWE-347: Improper Verification of Cryptographic Signature (#6336) 2025-12-07 14:16:39 +05:30
Anoop M D
facdf3264a feat: changes to incorporate oc schema updates (#6335)
* feat: changes to incorporate oc schema updates
* chore: fixed oc types resolution issue
2025-12-07 06:05:48 +05:30
naman-bruno
4ffb447c53 fix: path for newly added collection & remove option for outside collections (#6331)
* fixes

* fixes

* fix
2025-12-06 18:43:53 +05:30
Sanjai Kumar
3e5ae613f5 feat: Increase visibility of text in Request tabs (#6243)
* refactor(RequestTabs): update tab width calculation and improve styling

* refactor: replace close icon implementation with GradientCloseButton and adjust styles

* changes: design

* fix: failing tests

* fixes

* fixes: coderabbit

* fixes

* fixes

* gradient color fix

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-06 18:42:57 +05:30
naman-bruno
42bef4ae1e fix: traffic light styling on light mode (#6333)
* fix

* fixes
2025-12-06 18:16:37 +05:30
naman-bruno
e93e545b81 improve: tests (#6321)
* improve: tests

* fixes

* fixes
2025-12-06 15:36:58 +05:30
Abhishek S Lal
4a8d787f31 feat: Moved Workspace Selector to the Titlebar of the window. (#6319)
* refactor: update sidebar components and styles, replace TitleBar with SidebarHeader, and enhance collections search functionality

* refactor: improve event listener management in AppTitleBar and clean up SidebarHeader styles

* fix: ensure safe access to layout preferences in AppTitleBar and set default order in SidebarHeader

* refactor: centralize toTitleCase utility and remove redundant implementations in AppTitleBar and WorkspaceSelector

* feat: enhance accessibility and testing for sidebar and devtools toggle buttons in AppTitleBar

* chore: quick fix on a flaky test

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-06 02:07:05 +05:30
Bijin A B
f5211f6a08 Update quotes rule for string in CODING_STANDARDS.md (#6327) 2025-12-06 02:01:03 +05:30
Sanjai Kumar
57222d2500 feat: enhance collection settings with environment modals (#6242)
* feat: enhance collection settings with environment modals

* refactor: remove unused environment modal state and simplify Info component structure
2025-12-05 19:20:04 +05:30
Sanjai Kumar
f479e0d325 refactor: Rename runtime to runDuration (#6323)
* refactor: Rename 'runtime' to 'runDuration'

* revert changes made in report.html
2025-12-05 19:17:06 +05:30
naman-bruno
5302addda0 fix: clone collection (#6322)
* fix: clone collection
2025-12-05 17:26:06 +05:30
Sanjai Kumar
80b017f224 feat: Include pre-request and post-response tests in JUnit reports (#6284)
* enhance: JUnit output to include preRequest and postResponse test results

* fix: lint
2025-12-05 12:04:32 +05:30
Bijin A B
b18d582004 Merge pull request #6310 from sanjaikumar-bruno/chore/eslint-ignore-paths
chore: update ESLint configuration to ignore additional directories
2025-12-04 19:23:25 +05:30
Bijin A B
109394c65b Merge pull request #6308 from Pragadesh-45/fix/6254
feat: Streamline gRPC requests to use right context
2025-12-04 18:58:10 +05:30
Sid
c355153f26 Revert: Re-add post response vars (#6307)
* Partial Revert "remove: presets and response var (#6195)"

This reverts commit 786a3414b8 while keeping code related to presets deleted

* revert: remove global environment variables assignment
2025-12-04 18:04:47 +05:30
Pragadesh-45
b87a02beb3 feat: Streamline gRPC requests to use right context 2025-12-04 18:00:32 +05:45
sanjai
4624ffb116 chore: update ESLint configuration to ignore additional directories 2025-12-04 16:34:48 +05:30
Sid
a9ce97fb1b fix: update content security policy to remove unsafe-inline (#6305) 2025-12-04 12:40:52 +05:30
Pooja
72ce6cadeb fix: request and response pane height (#6294) 2025-12-04 12:31:57 +05:30
Bijin A B
c4ff2918a2 Merge pull request #6080 from dssagar93/feature/auto-scroll-on-tab-change
Auto scroll to show this item when its tab becomes active
2025-12-04 10:05:32 +05:30
Bijin A B
9972eb3de6 Merge branch 'main' of github.com:usebruno/bruno into feature/auto-scroll-on-tab-change 2025-12-04 05:15:39 +05:30
naman-bruno
ebe0203415 init: workspaces (#6264)
* init: workspaces
2025-12-04 04:56:43 +05:30
IDCT Bartosz Pachołek
b3ef91fe8e Enabled ARM64 build for Windows. 2025-12-03 23:57:15 +01:00
SAGAR KHATRI
f7ea1f8dbb Added semi colon 2025-11-13 19:31:21 +05:30
SAGAR KHATRI
cf19035b0b Merge branch 'usebruno:main' into feature/auto-scroll-on-tab-change 2025-11-12 23:53:05 +05:30
SAGAR KHATRI
d9a3f74cb7 Auto scroll to show this item when its tab becomes active 2025-11-12 20:50:44 +05:30
374 changed files with 22477 additions and 5810 deletions

View File

@@ -6,7 +6,7 @@
- Use 2 spaces for indentation. No tabs, just spaces keeps everything neat and uniform.
- Stick to single quotes for strings. Double quotes are cool elsewhere, but here we go single.
- Stick to single quotes for strings. For JSX/TSX attributes, use double quotes (e.g., <svg xmlns="..." viewBox="...">) to follow React conventions.
- Always add semicolons at the end of statements. It's like putting a period at the end of a sentence clarity matters.

View File

@@ -18,7 +18,9 @@ module.exports = runESMImports().then(() => defineConfig([
'**/dist/**/*',
'**/*.bru',
'packages/bruno-js/src/sandbox/bundle-browser-rollup.js',
'packages/bruno-app/public/static/**/*'
'packages/bruno-app/public/static/**/*',
'packages/bruno-app/.next/**/*',
'packages/bruno-electron/web/**/*'
]
},
{

1977
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,8 @@
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"js-yaml": "^4.1.0",
"xml2js": "^0.6.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
@@ -83,6 +85,7 @@
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "5.17.12",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",

View File

@@ -0,0 +1,129 @@
const yamlPlugin = (cm) => {
cm.defineMode('yaml', function () {
var cons = ['true', 'false', 'on', 'off', 'yes', 'no'];
var keywordRegex = new RegExp('\\b((' + cons.join(')|(') + '))$', 'i');
return {
token: function (stream, state) {
var ch = stream.peek();
var esc = state.escaped;
state.escaped = false;
/* comments */
if (ch == '#' && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) {
stream.skipToEnd();
return 'comment';
}
if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) return 'string';
if (state.literal && stream.indentation() > state.keyCol) {
stream.skipToEnd();
return 'string';
} else if (state.literal) {
state.literal = false;
}
if (stream.sol()) {
state.keyCol = 0;
state.pair = false;
state.pairStart = false;
/* document start */
if (stream.match('---')) {
return 'def';
}
/* document end */
if (stream.match('...')) {
return 'def';
}
/* array list item */
if (stream.match(/\s*-\s+/)) {
return 'meta';
}
}
/* inline pairs/lists */
if (stream.match(/^(\{|\}|\[|\])/)) {
if (ch == '{') state.inlinePairs++;
else if (ch == '}') state.inlinePairs--;
else if (ch == '[') state.inlineList++;
else state.inlineList--;
return 'meta';
}
/* list separator */
if (state.inlineList > 0 && !esc && ch == ',') {
stream.next();
return 'meta';
}
/* pairs separator */
if (state.inlinePairs > 0 && !esc && ch == ',') {
state.keyCol = 0;
state.pair = false;
state.pairStart = false;
stream.next();
return 'meta';
}
/* start of value of a pair */
if (state.pairStart) {
/* block literals */
if (stream.match(/^\s*(\||\>)\s*/)) {
state.literal = true;
return 'meta';
}
/* references */
if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) {
return 'variable-2';
}
/* numbers */
if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) {
return 'number';
}
if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) {
return 'number';
}
/* keywords */
if (stream.match(keywordRegex)) {
return 'keyword';
}
}
/* pairs (associative arrays) -> key */
if (
!state.pair
&& stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)
) {
state.pair = true;
state.keyCol = stream.indentation();
return 'atom';
}
if (state.pair && stream.match(/^:\s*/)) {
state.pairStart = true;
return 'meta';
}
/* nothing found, continue */
state.pairStart = false;
state.escaped = ch == '\\';
stream.next();
return null;
},
startState: function () {
return {
pair: false,
pairStart: false,
keyCol: 0,
inlinePairs: 0,
inlineList: 0,
literal: false,
escaped: false
};
},
lineComment: '#',
fold: 'indent'
};
});
cm.defineMIME('text/x-yaml', 'yaml');
cm.defineMIME('text/yaml', 'yaml');
};
export default yamlPlugin;

View File

@@ -0,0 +1,65 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
height: calc(100vh - 4rem);
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
font-family: ${(props) => (props.font ? props.font : 'default')};
line-break: anywhere;
}
.CodeMirror-dialog {
overflow: visible;
input {
background: transparent;
border: 1px solid #d3d6db;
outline: none;
border-radius: 0px;
}
}
.CodeMirror-overlayscroll-horizontal div,
.CodeMirror-overlayscroll-vertical div {
background: #d2d7db;
}
textarea.cm-editor {
position: relative;
}
// Todo: dark mode temporary fix
// Clean this
.CodeMirror.cm-s-monokai {
.CodeMirror-overlayscroll-horizontal div,
.CodeMirror-overlayscroll-vertical div {
background: #444444;
}
}
.cm-s-monokai span.cm-property,
.cm-s-monokai span.cm-attribute {
color: #9cdcfe !important;
}
.cm-s-monokai span.cm-string {
color: #ce9178 !important;
}
.cm-s-monokai span.cm-number {
color: #b5cea8 !important;
}
.cm-s-monokai span.cm-atom {
color: #569cd6 !important;
}
.cm-variable-valid {
color: green;
}
.cm-variable-invalid {
color: red;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import StyledWrapper from './StyledWrapper';
import yamlPlugin from './Plugins/Yaml/index';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
this.cachedValue = props.value || '';
this.variables = {};
this.lintOptions = {
esversion: 11,
expr: true,
asi: true
};
}
componentWillMount() {
switch (this.props.mode) {
case 'yaml':
// YAML linting and hightlighting plugin
yamlPlugin(CodeMirror);
break;
default:
break;
}
}
componentDidMount() {
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
mode: this.props.mode || 'application/text',
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
showCursorWhenSelecting: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
lint: this.lintOptions,
readOnly: this.props.readOnly,
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
: cm.replaceSelection(' ', 'end');
},
'Shift-Tab': 'indentLess',
'Ctrl-Space': 'autocomplete',
'Cmd-Space': 'autocomplete',
'Ctrl-Y': 'foldAll',
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll'
}
}));
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
}
}
componentDidUpdate(prevProps) {
this.ignoreChangeEvent = true;
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor = null;
}
}
render() {
if (this.editor) {
this.editor.refresh();
}
return (
<StyledWrapper
className="h-full w-full graphiql-container"
aria-label="Code Editor"
font={this.props.font}
ref={(node) => {
this._node = node;
}}
/>
);
}
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
this.cachedValue = this.editor.getValue();
if (this.props.onEdit) {
this.props.onEdit(this.cachedValue);
}
}
};
}

View File

@@ -0,0 +1,51 @@
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from './CodeEditor/index';
import { IconDeviceFloppy } from '@tabler/icons';
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
const FileEditor = ({ apiSpec }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [content, setContent] = useState(apiSpec?.raw);
const onEdit = (value) => {
setContent(value);
};
const onSave = () => {
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
};
const hasChanges = Boolean(content != apiSpec?.raw);
const editorMode = 'yaml';
return (
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={content}
onEdit={onEdit}
onSave={onSave}
mode={editorMode}
font={get(preferences, 'font.codeFont', 'default')}
/>
<IconDeviceFloppy
onClick={onSave}
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer oapcity-100' : 'cursor-default opacity-50'
}`}
/>
</div>
);
};
export default FileEditor;

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.swagger-root {
height: calc(100vh - 4rem);
border: solid 1px ${(props) => props.theme.codemirror.border};
&.dark {
.swagger-ui {
filter: invert(88%) hue-rotate(180deg);
}
.swagger-ui .microlight {
filter: invert(100%) hue-rotate(180deg);
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,19 @@
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const Swagger = ({ string }) => {
const { displayedTheme } = useTheme();
console.log('string', string);
return (
<StyledWrapper>
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
<SwaggerUI spec={string} />
</div>
</StyledWrapper>
);
};
export default Swagger;

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.menu-icon {
cursor: pointer;
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
}
div.dropdown-item.menu-item {
color: ${(props) => props.theme.colors.danger};
&:hover {
background-color: ${(props) => props.theme.colors.bg.danger};
color: white;
}
}
.react-tooltip {
z-index: 10;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,97 @@
import React, { forwardRef, useRef } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import FileEditor from './FileEditor';
import Dropdown from 'components/Dropdown';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import { Suspense } from 'react';
import Swagger from './Renderers/Swagger';
import toast from 'react-hot-toast';
const ApiSpecPanel = () => {
const dispatch = useDispatch();
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
const { apiSpecs, activeApiSpecUid } = useSelector((state) => state.apiSpec);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
const { filename, pathname, raw, uid } = apiSpec || {};
if (!uid) {
return <div className="p-4 opacity-50">API Spec not found!</div>;
}
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref}>
<IconDots size={22} />
</div>
);
});
const handleOpenApiSpec = () => {
dispatch(openApiSpec()).catch(
(err) => console.log(err) && toast.error('An error occurred while opening the API spec')
);
};
return (
<StyledWrapper className="flex flex-col flex-grow relative">
{createApiSpecModalOpen ? <CreateApiSpec onClose={() => setCreateApiSpecModalOpen(false)} /> : null}
<div className="p-3 mb-2 w-full flex flex-row justify-between grid grid-cols-3">
<div className="flex flex-row justify-start gap-x-4 col-span-1">
<div className="flex w-fit items-center cursor-pointer">
<IconFileCode size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">API Designer</span>
</div>
</div>
<div className="w-full col-span-1 flex justify-center" title={pathname}>
{filename}
</div>
<div className="menu-icon pr-2 col-span-1 flex justify-end">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setCreateApiSpecModalOpen(true);
}}
>
Create API Spec
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleOpenApiSpec();
}}
>
Open API Spec
</div>
</Dropdown>
</div>
</div>
<section className="main flex flex-grow px-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<FileEditor apiSpec={apiSpec} />
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger string={raw} />
</Suspense>
</div>
</div>
</section>
</StyledWrapper>
);
};
export default ApiSpecPanel;

View File

@@ -0,0 +1,198 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 36px;
display: flex;
align-items: center;
background: ${(props) => props.theme.sidebar.bg};
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
-webkit-app-region: drag;
user-select: none;
.titlebar-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
padding: 0 12px;
padding-left: 70px; /* Space for macOS window controls */
transition: padding-left 0.15s ease;
}
/* When in full screen, no traffic lights so reduce padding */
&.fullscreen .titlebar-content {
padding-left: 4px;
}
/* Remove drag region from interactive elements */
.workspace-name-container,
.dropdown-item,
.home-button,
.dropdown,
button {
-webkit-app-region: no-drag;
}
/* Left section */
.titlebar-left {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 10px;
-webkit-app-region: no-drag;
}
/* When in full screen, no traffic lights so remove margin-left */
&.fullscreen .titlebar-left {
margin-left: 0px;
}
/* Workspace Name Dropdown Trigger */
.workspace-name-container {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.workspace-name {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.sidebar.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.chevron-icon {
flex-shrink: 0;
color: ${(props) => props.theme.sidebar.muted};
transition: transform 0.2s ease;
}
}
/* Center section - Bruno branding */
.titlebar-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
.bruno-text {
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.text};
letter-spacing: 0.5px;
}
}
/* Right section */
.titlebar-right {
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
/* Workspace Dropdown Styles */
.workspace-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px !important;
margin: 0 !important;
&.active {
.check-icon {
opacity: 1;
}
}
&:hover {
.pin-btn:not(.pinned) {
opacity: 1;
}
}
.workspace-name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 400;
color: ${(props) => props.theme.dropdown.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
flex-shrink: 0;
pointer-events: none;
> * {
pointer-events: auto;
}
}
.check-icon {
color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow};
flex-shrink: 0;
}
.pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.dropdown.mutedText};
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
opacity: 0;
&.pinned {
opacity: 1;
}
&:hover {
background: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.dropdown.mutedText};
}
}
}
/* Adjust for non-macOS platforms */
body:not(.os-mac) & {
.titlebar-content {
padding-left: 12px;
}
}
/* Leave room for Windows caption buttons when the overlay is enabled */
body.os-windows & {
.titlebar-content {
padding-right: 120px;
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,252 @@
import React from 'react';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showHomePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import StyledWrapper from './StyledWrapper';
import { toTitleCase } from 'utils/common/index';
const AppTitleBar = () => {
const dispatch = useDispatch();
const [isFullScreen, setIsFullScreen] = useState(false);
// Listen for fullscreen changes
useEffect(() => {
const { ipcRenderer } = window;
if (!ipcRenderer) return;
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
setIsFullScreen(true);
});
const removeLeaveFullScreenListener = ipcRenderer.on('main:leave-full-screen', () => {
setIsFullScreen(false);
});
return () => {
removeEnterFullScreenListener();
removeLeaveFullScreenListener();
};
}, []);
// Get workspace info
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
// Sort workspaces according to preferences
const sortedWorkspaces = useMemo(() => {
return sortWorkspaces(workspaces, preferences);
}, [workspaces, preferences]);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const WorkspaceName = forwardRef((props, ref) => {
return (
<div ref={ref} className="workspace-name-container" {...props}>
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
</div>
);
});
const handleHomeClick = () => {
dispatch(showHomePage());
};
const handleWorkspaceSwitch = (workspaceUid) => {
dispatch(switchWorkspace(workspaceUid));
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
};
const handleOpenWorkspace = async () => {
try {
await dispatch(openWorkspaceDialog());
toast.success('Workspace opened successfully');
} catch (error) {
toast.error(error.message || 'Failed to open workspace');
}
};
const handleCreateWorkspace = () => {
setCreateWorkspaceModalOpen(true);
};
const handlePinWorkspace = useCallback((workspaceUid, e) => {
e.preventDefault();
e.stopPropagation();
const newPreferences = toggleWorkspacePin(workspaceUid, preferences);
dispatch(savePreferences(newPreferences));
}, [dispatch, preferences]);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const handleToggleSidebar = () => {
dispatch(toggleSidebarCollapse());
};
const handleToggleDevtools = () => {
if (isConsoleOpen) {
dispatch(closeConsole());
} else {
dispatch(openConsole());
}
};
const handleToggleVerticalLayout = () => {
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences?.layout || {},
responsePaneOrientation: newOrientation
}
};
dispatch(savePreferences(updatedPreferences));
};
// Build workspace menu items
const workspaceMenuItems = useMemo(() => {
const items = sortedWorkspaces.map((workspace) => {
const isActive = workspace.uid === activeWorkspaceUid;
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
return {
id: workspace.uid,
label: toTitleCase(workspace.name),
onClick: () => handleWorkspaceSwitch(workspace.uid),
className: `workspace-item ${isActive ? 'active' : ''}`,
rightSection: (
<div className="workspace-actions">
{workspace.type !== 'default' && (
<ActionIcon
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
size="sm"
>
{isPinned ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
</ActionIcon>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
</div>
)
};
});
// Add label and action items
items.push(
{ type: 'label', label: 'Workspaces' },
{
id: 'create-workspace',
leftSection: IconPlus,
label: 'Create workspace',
onClick: handleCreateWorkspace
},
{
id: 'open-workspace',
leftSection: IconFolder,
label: 'Open workspace',
onClick: handleOpenWorkspace
}
);
return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
)}
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
<ActionIcon
onClick={handleHomeClick}
label="Home"
size="lg"
className="home-button"
>
<IconHome size={16} stroke={1.5} />
</ActionIcon>
{/* Workspace Dropdown */}
<MenuDropdown
data-testid="workspace-menu"
items={workspaceMenuItems}
placement="bottom-start"
selectedItemId={activeWorkspaceUid}
>
<WorkspaceName />
</MenuDropdown>
</div>
{/* Center section: Bruno logo + text */}
<div className="titlebar-center">
<Bruno width={18} />
<span className="bruno-text">Bruno</span>
</div>
{/* Right section: Action buttons */}
<div className="titlebar-right">
{/* Toggle sidebar */}
<ActionIcon
onClick={handleToggleSidebar}
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
size="lg"
data-testid="toggle-sidebar-button"
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
</ActionIcon>
{/* Toggle devtools */}
<ActionIcon
onClick={handleToggleDevtools}
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
size="lg"
data-testid="toggle-devtools-button"
>
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
</ActionIcon>
{/* Toggle vertical layout */}
<ActionIcon
onClick={handleToggleVerticalLayout}
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
size="lg"
data-testid="toggle-vertical-layout-button"
>
{orientation === 'horizontal' ? (
<IconLayoutColumns size={16} stroke={1.5} />
) : (
<IconLayoutRows size={16} stroke={1.5} />
)}
</ActionIcon>
</div>
</div>
</StyledWrapper>
);
};
export default AppTitleBar;

View File

@@ -373,7 +373,7 @@ const ClientCertSettings = ({ collection }) => {
) : null}
</div>
<div className="mt-6 flex flex-row gap-2 items-center">
<button type="submit" className="submit btn btn-sm btn-secondary">
<button type="submit" className="submit btn btn-sm btn-secondary" data-testid="add-client-cert">
Add
</button>
<div className="h-4 border-l border-gray-600"></div>

View File

@@ -1,78 +1,77 @@
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader,
setCollectionHeaders
} from 'providers/ReduxStore/slices/collections';
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
const headers = collection.draft?.root
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
addCollectionHeader({
collectionUid: collection.uid
})
);
};
const handleHeadersChange = useCallback((updatedHeaders) => {
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: updatedHeaders }));
}, [dispatch, collection.uid]);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleHeaderValueChange = (e, _header, type) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {
header.value = e.target.value;
break;
}
case 'enabled': {
header.enabled = e.target.checked;
break;
}
}
dispatch(
updateCollectionHeader({
header: header,
collectionUid: collection.uid
})
);
};
const handleRemoveHeader = (header) => {
dispatch(
deleteCollectionHeader({
headerUid: header.uid,
collectionUid: collection.uid
})
);
const columns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
collection={collection}
placeholder={isLastEmptyRow ? 'Name' : ''}
/>
)
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={onChange}
collection={collection}
autocomplete={MimeTypes}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
description: ''
};
if (isBulkEditMode) {
@@ -83,7 +82,7 @@ const Headers = ({ collection }) => {
</div>
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onChange={handleHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
@@ -96,86 +95,17 @@ const Headers = ({ collection }) => {
<div className="text-xs mb-4 text-muted">
Add request headers that will be sent with every request in this collection.
</div>
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{headers && headers.length
? headers.map((header) => {
return (
<tr key={header.uid}>
<td>
<SingleLineEditor
value={header.name}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'name'
)}
autocomplete={headerAutoCompleteList}
collection={collection}
/>
</td>
<td>
<SingleLineEditor
value={header.value}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'value'
)}
collection={collection}
autocomplete={MimeTypes}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={header.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<div className="flex justify-between mt-2">
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<EditableTable
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
@@ -184,4 +114,5 @@ const Headers = ({ collection }) => {
</StyledWrapper>
);
};
export default Headers;

View File

@@ -3,15 +3,23 @@ import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconBox, IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ShareCollection from 'components/ShareCollection/index';
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
const Info = ({ collection }) => {
const dispatch = useDispatch();
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
const isCollectionLoading = areItemsLoading(collection);
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const collectionEnvironmentCount = collection.environments?.length || 0;
const globalEnvironmentCount = globalEnvironments?.length || 0;
const handleToggleShowShareCollectionModal = (value) => (e) => {
toggleShowShareCollectionModal(value);
};
@@ -39,9 +47,24 @@ const Info = ({ collection }) => {
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-medium">Environments</div>
<div className="mt-1 text-muted text-xs">
{collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured
<div className="font-medium text-sm">Environments</div>
<div className="mt-1 flex flex-col gap-1">
<button
type="button"
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
onClick={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
}}
>
{collectionEnvironmentCount} collection environment{collectionEnvironmentCount !== 1 ? 's' : ''}
</button>
<button
type="button"
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
onClick={() => dispatch(updateGlobalEnvironmentSettingsModalVisibility(true))}
>
{globalEnvironmentCount} global environment{globalEnvironmentCount !== 1 ? 's' : ''}
</button>
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,6 +20,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -1,160 +1,81 @@
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import {
addCollectionVar,
deleteCollectionVar,
updateCollectionVar
} from 'providers/ReduxStore/slices/collections/index';
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addVar = () => {
dispatch(
addCollectionVar({
collectionUid: collection.uid,
type: varType
})
);
};
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleVarChange = (e, v, type) => {
const _var = cloneDeep(v);
switch (type) {
case 'name': {
const value = e.target.value;
if (variableNameRegex.test(value) === false) {
toast.error(
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
);
return;
}
const handleVarsChange = useCallback((updatedVars) => {
dispatch(setCollectionVars({ collectionUid: collection.uid, vars: updatedVars, type: varType }));
}, [dispatch, collection.uid, varType]);
_var.name = value;
break;
}
case 'value': {
_var.value = e.target.value;
break;
}
case 'enabled': {
_var.enabled = e.target.checked;
break;
}
const getRowError = useCallback((row, index, key) => {
if (key !== 'name') return null;
if (!row.name || row.name.trim() === '') return null;
if (!variableNameRegex.test(row.name)) {
return 'Variable contains invalid characters. Must only contain alphanumeric characters, "-", "_", "."';
}
dispatch(
updateCollectionVar({
type: varType,
var: _var,
collectionUid: collection.uid
})
);
};
return null;
}, []);
const handleRemoveVar = (_var) => {
dispatch(
deleteCollectionVar({
type: varType,
varUid: _var.uid,
collectionUid: collection.uid
})
);
const columns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '40%'
},
{
key: 'value',
name: varType === 'request' ? 'Value' : (
<div className="flex items-center">
<span>Expr</span>
<InfoTip content="You can write any valid JS Template Literal here" infotipId={`collection-${varType}-var`} />
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
...(varType === 'response' ? { local: false } : {})
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Name</td>
{varType === 'request' ? (
<td>
<div className="flex items-center">
<span>Value</span>
</div>
</td>
) : (
<td>
<div className="flex items-center">
<span>Expr</span>
<InfoTip content="You can write any valid JS Template Literal here" infotipId="request-var" />
</div>
</td>
)}
<td></td>
</tr>
</thead>
<tbody>
{vars && vars.length
? vars.map((_var) => {
return (
<tr key={_var.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={_var.name}
className="mousetrap"
onChange={(e) => handleVarChange(e, _var, 'name')}
/>
</td>
<td>
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleVarChange(
{
target: {
value: newValue
}
},
_var,
'value'
)}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={_var.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleVarChange(e, _var, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-var text-link pr-2 py-3 mt-2 select-none" onClick={addVar}>
+ Add
</button>
<EditableTable
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
/>
</StyledWrapper>
);
};
export default VarsTable;

View File

@@ -4,18 +4,23 @@ import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import DeprecationWarning from 'components/DeprecationWarning';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Pre Request</div>
<VarsTable collection={collection} vars={requestVars} varType="request" />
</div>
<div className="flex-1">
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<VarsTable collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save

View File

@@ -32,17 +32,32 @@ const CollectionSettings = ({ collection }) => {
const hasTests = root?.request?.tests;
const hasDocs = root?.docs;
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
const headers = collection.draft?.root
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const activeHeadersCount = headers.filter((header) => header.enabled).length;
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
const authMode = (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {})).mode || 'none';
const requestVars = collection.draft?.root
? get(collection, 'draft.root.request.vars.req', [])
: get(collection, 'root.request.vars.req', []);
const responseVars = collection.draft?.root
? get(collection, 'draft.root.request.vars.res', [])
: get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const authMode
= (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {}))
.mode || 'none';
const proxyConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.proxy', {}) : get(collection, 'brunoConfig.proxy', {});
const proxyConfig = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.proxy', {})
: get(collection, 'brunoConfig.proxy', {});
const proxyEnabled = proxyConfig.hostname ? true : false;
const clientCertConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.clientCertificates.certs', []) : get(collection, 'brunoConfig.clientCertificates.certs', []);
const protobufConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.protobuf', {}) : get(collection, 'brunoConfig.protobuf', {});
const clientCertConfig = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.clientCertificates.certs', [])
: get(collection, 'brunoConfig.clientCertificates.certs', []);
const protobufConfig = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.protobuf', {})
: get(collection, 'brunoConfig.protobuf', {});
const getTabPanel = (tab) => {
switch (tab) {
@@ -68,11 +83,7 @@ const CollectionSettings = ({ collection }) => {
return <ProxySettings collection={collection} />;
}
case 'clientCert': {
return (
<ClientCertSettings
collection={collection}
/>
);
return <ClientCertSettings collection={collection} />;
}
case 'protobuf': {
return <Protobuf collection={collection} />;

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
display: inline-block;
`;
export default Wrapper;

View File

@@ -0,0 +1,172 @@
import React, { useRef, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Dropdown from 'components/Dropdown';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { generateUniqueRequestName } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { IconApi, IconBrandGraphql, IconPlugConnected, IconCode, IconPlus } from '@tabler/icons';
const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated, placement = 'bottom' }) => {
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = collections?.find((c) => c.uid === collectionUid);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
if (!collection) {
return null;
}
const handleCreateHttpRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'http-request',
requestUrl: '',
requestMethod: 'GET',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGraphQLRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'graphql-request',
requestUrl: '',
requestMethod: 'POST',
collectionUid: collection.uid,
itemUid: itemUid,
body: {
mode: 'graphql',
graphql: {
query: '',
variables: ''
}
}
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateWebSocketRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newWsRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
requestMethod: 'ws',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGrpcRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newGrpcRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
return (
<Dropdown onCreate={onDropdownCreate} icon={<IconPlus size={16} strokeWidth={2} />} placement={placement}>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateHttpRequest();
}}
>
<span className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</span>
HTTP
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGraphQLRequest();
}}
>
<span className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</span>
GraphQL
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateWebSocketRequest();
}}
>
<span className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</span>
WebSocket
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGrpcRequest();
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
gRPC
</div>
</Dropdown>
);
};
export default CreateUntitledRequest;

View File

@@ -168,7 +168,7 @@ const StyledWrapper = styled.div`
position: sticky;
top: 0;
z-index: 10;
td {
padding: 8px 12px;
font-weight: 500;
@@ -256,10 +256,8 @@ const StyledWrapper = styled.div`
}
.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;
@@ -267,13 +265,11 @@ const StyledWrapper = styled.div`
.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;
@@ -282,28 +278,17 @@ const StyledWrapper = styled.div`
align-items: center !important;
min-height: 40px !important;
flex-shrink: 0 !important;
> div {
color: ${(props) => props.theme.console.buttonColor};
font-size: ${(props) => props.theme.font.size.sm} !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;

View File

@@ -7,7 +7,7 @@ import {
IconNetwork
} from '@tabler/icons';
import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
import QueryResult from 'components/ResponsePane/QueryResult';
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
import Network from 'components/ResponsePane/Timeline/TimelineItem/Network';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common/index';
@@ -116,7 +116,7 @@ const ResponseTab = ({ response, request, collection }) => {
<h4>Response Body</h4>
<div className="response-body-container">
{response?.data || response?.dataBuffer ? (
<QueryResult
<QueryResponse
item={{ uid: uuid() }}
collection={collection}
data={response.data}

View File

@@ -25,6 +25,16 @@ const Wrapper = styled.div`
padding-top: 0;
padding-bottom: 0;
[role="menu"] {
outline: none;
&:focus {
outline: none;
}
&:focus-visible {
outline: none;
}
}
.label-item {
display: flex;
align-items: center;
@@ -59,6 +69,10 @@ const Wrapper = styled.div`
}
}
.dropdown-label {
flex: 1;
}
.dropdown-icon {
flex-shrink: 0;
width: 16px;
@@ -70,10 +84,31 @@ const Wrapper = styled.div`
opacity: 0.8;
}
.dropdown-right-section {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.selected-focused:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:focus-visible:not(:disabled) {
outline: none;
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:focus:not(:focus-visible) {
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;

View File

@@ -0,0 +1,154 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.table-container {
overflow-y: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: ${(props) => props.theme.workspace.environments.indentBorder};
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: ${(props) => props.theme.font.size.base};
}
thead {
color: ${(props) => props.theme.colors.text} !important;
background: ${(props) => props.theme.sidebar.bg};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
border: none !important;
td {
padding: 8px 10px;
border-top: none !important;
border-left: none !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
vertical-align: middle;
&:nth-child(1) {
width: 25px !important;
border-right: none;
}
&:last-child {
border-right: none;
}
}
}
tbody {
tr {
transition: background 0.1s ease;
&:last-child td {
border-bottom: none;
}
td {
padding: 2px 10px;
border-top: none !important;
border-left: none !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
vertical-align: middle;
&:nth-child(1) {
width: 25px;
border-right: none;
text-align: center;
vertical-align: middle;
line-height: 1;
input[type='checkbox'] {
vertical-align: baseline;
display: inline-block;
}
}
&:last-child {
border-right: none;
}
}
}
}
.tooltip-mod {
font-size: 11px !important;
max-width: 200px !important;
}
input[type='text'] {
width: 100%;
outline: none !important;
background-color: transparent;
color: ${(props) => props.theme.text};
padding: 0;
font-size: 12px;
border-radius: 4px;
transition: all 0.15s ease;
&:focus {
outline: none !important;
}
}
input[type='checkbox'] {
cursor: pointer;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.workspace.accent};
vertical-align: middle;
margin: 0;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
.drag-handle {
.icon-grip,
.icon-minus {
color: ${(props) => props.theme.colors.text.muted};
}
}
select {
background-color: transparent;
color: ${(props) => props.theme.text};
border: none;
outline: none;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
option {
background-color: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,318 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const EditableTable = ({
columns,
rows,
onChange,
defaultRow,
getRowError,
showCheckbox = true,
showDelete = true,
checkboxLabel = '',
checkboxKey = 'enabled',
reorderable = false,
onReorder,
showAddRow = true
}) => {
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [dragStart, setDragStart] = useState(null);
const createEmptyRow = useCallback(() => {
const newUid = uuid();
emptyRowUidRef.current = newUid;
return {
uid: newUid,
[checkboxKey]: true,
...defaultRow
};
}, [defaultRow, checkboxKey]);
const rowsWithEmpty = useMemo(() => {
if (!showAddRow) {
return rows;
}
if (rows.length === 0) {
return [createEmptyRow()];
}
const lastRow = rows[rows.length - 1];
const keyColumn = columns.find((col) => col.isKeyField);
if (keyColumn) {
const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key];
const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === '');
if (isLastRowEmpty) {
return rows;
}
}
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
emptyRowUidRef.current = uuid();
}
return [...rows, {
uid: emptyRowUidRef.current,
[checkboxKey]: true,
...defaultRow
}];
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
const isEmptyRow = useCallback((row) => {
const keyColumn = columns.find((col) => col.isKeyField);
if (!keyColumn) return false;
const value = keyColumn.getValue ? keyColumn.getValue(row) : row[keyColumn.key];
return !value || (typeof value === 'string' && value.trim() === '');
}, [columns]);
const isLastEmptyRow = useCallback((row, index) => {
if (!showAddRow) return false;
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
const handleValueChange = useCallback((rowUid, key, value) => {
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
if (rowIndex === -1) return;
const currentRow = rowsWithEmpty[rowIndex];
const isLast = rowIndex === rowsWithEmpty.length - 1;
const wasEmpty = isEmptyRow(currentRow);
const keyColumn = columns.find((col) => col.isKeyField);
const isKeyFieldChange = keyColumn && keyColumn.key === key;
let updatedRows = rowsWithEmpty.map((row) => {
if (row.uid === rowUid) {
return { ...row, [key]: value };
}
return row;
});
// Only add a new empty row when the key field is filled
if (showAddRow && isLast && wasEmpty && isKeyFieldChange && value && value.trim() !== '') {
emptyRowUidRef.current = uuid();
updatedRows.push({
uid: emptyRowUidRef.current,
[checkboxKey]: true,
...defaultRow
});
}
const hasAnyValue = (row) => {
for (const col of columns) {
const val = col.getValue ? col.getValue(row) : row[col.key];
const defaultVal = defaultRow[col.key];
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
return true;
}
}
return false;
};
const result = updatedRows.filter((row, i) => {
if (showAddRow && i === updatedRows.length - 1) {
return hasAnyValue(row);
}
return true;
});
onChange(result);
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
const handleCheckboxChange = useCallback((rowUid, checked) => {
handleValueChange(rowUid, checkboxKey, checked);
}, [handleValueChange, checkboxKey]);
const handleRemoveRow = useCallback((rowUid) => {
const filteredRows = rows.filter((row) => row.uid !== rowUid);
onChange(filteredRows);
}, [rows, onChange]);
const getColumnWidth = useCallback((column) => {
if (column.width) return column.width;
return 'auto';
}, []);
const handleDragStart = useCallback((e, index) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index);
setDragStart(index);
}, []);
const handleDragOver = useCallback((e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setHoveredRow(index);
}, []);
const handleDrop = useCallback((e, toIndex) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (fromIndex !== toIndex && onReorder) {
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setDragStart(null);
setHoveredRow(null);
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setDragStart(null);
setHoveredRow(null);
}, []);
const renderCell = useCallback((column, row, rowIndex) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const value = column.getValue ? column.getValue(row) : row[column.key];
const error = getRowError?.(row, rowIndex, column.key);
if (column.render) {
return column.render({
row,
value,
rowIndex,
isLastEmptyRow: isEmpty,
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue),
error
});
}
return (
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
value={value || ''}
readOnly={column.readOnly}
placeholder={isEmpty ? column.placeholder || column.name : ''}
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
/>
{error && !isEmpty && (
<span>
<IconAlertCircle
data-tooltip-id={`error-${row.uid}-${column.key}`}
className="text-red-600 cursor-pointer"
size={20}
/>
<Tooltip
className="tooltip-mod"
id={`error-${row.uid}-${column.key}`}
html={error}
/>
</span>
)}
</div>
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper>
<div className="table-container" ref={tableRef}>
<table>
<thead>
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
{column.name}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
</thead>
<tbody>
{rowsWithEmpty.map((row, rowIndex) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<tr
key={row.uid}
draggable={canDrag}
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? handleDragEnd : undefined}
onMouseEnter={() => setHoveredRow(rowIndex)}
onMouseLeave={() => setHoveredRow(null)}
>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
{hoveredRow === rowIndex && (
<>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</>
)}
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
checked={row[checkboxKey] ?? true}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button onClick={() => handleRemoveRow(row.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</StyledWrapper>
);
};
export default EditableTable;

View File

@@ -2,46 +2,55 @@ import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
border-radius: 0.9375rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.25rem 0.3rem 0.25rem 0.5rem;
user-select: none;
background-color: transparent;
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.bg};
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
line-height: 1rem;
transition: all 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder};
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBg};
}
.caret {
margin-left: 0.25rem;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
fill: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
align-self: center;
}
.env-icon {
margin-right: 0.25rem;
color: ${(props) => props.theme.dropdown.selectedColor};
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.icon};
}
.env-text {
color: ${(props) => props.theme.dropdown.selectedColor};
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.text};
display: block;
}
.env-separator {
color: #8c8c8c;
margin: 0 0.25rem;
opacity: 0.7;
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
margin: 0 0.35rem;
}
.env-text-inactive {
color: ${(props) => props.theme.dropdown.color};
font-size: ${(props) => props.theme.font.size.base};
opacity: 0.7;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
&.no-environments {
background-color: ${(props) => props.theme.sidebar.badge.bg};
border: 1px solid transparent;
color: ${(props) => props.theme.dropdown.secondaryText};
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.text};
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.bg};
border: 1px dashed ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.border};
&:hover {
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.hoverBorder};
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.hoverBg};
}
}
}

View File

@@ -3,7 +3,7 @@ import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ const EnvironmentSelector = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const [activeTab, setActiveTab] = useState('collection');
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
@@ -29,6 +27,8 @@ const EnvironmentSelector = ({ collection }) => {
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
const activeGlobalEnvironment = activeGlobalEnvironmentUid
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
: null;
@@ -79,9 +79,8 @@ const EnvironmentSelector = ({ collection }) => {
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
} else {
setShowGlobalSettings(true);
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
}
dropdownTippyRef.current.hide();
};
@@ -108,9 +107,8 @@ const EnvironmentSelector = ({ collection }) => {
// Modal handlers
const handleCloseSettings = () => {
setShowGlobalSettings(false);
setShowCollectionSettings(false);
dispatch(updateEnvironmentSettingsModalVisibility(false));
dispatch(updateGlobalEnvironmentSettingsModalVisibility(false));
};
// Calculate dropdown width based on the longest environment name.
@@ -164,7 +162,7 @@ const EnvironmentSelector = ({ collection }) => {
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
);
return (
@@ -176,7 +174,7 @@ const EnvironmentSelector = ({ collection }) => {
data-testid="environment-selector-trigger"
>
{displayContent}
<IconCaretDown className="caret" size={14} strokeWidth={2} />
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
);
});
@@ -220,7 +218,7 @@ const EnvironmentSelector = ({ collection }) => {
</div>
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{showGlobalSettings && (
{isGlobalEnvironmentSettingsModalOpen && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
@@ -229,13 +227,15 @@ const EnvironmentSelector = ({ collection }) => {
/>
)}
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
{isEnvironmentSettingsModalOpen && (
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
)}
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
}}
/>
)}
@@ -245,7 +245,7 @@ const EnvironmentSelector = ({ collection }) => {
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
}}
/>
)}
@@ -255,7 +255,7 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
setShowCollectionSettings(true);
dispatch(updateEnvironmentSettingsModalVisibility(true));
}}
/>
)}
@@ -266,7 +266,7 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
setShowCollectionSettings(true);
dispatch(updateEnvironmentSettingsModalVisibility(true));
}}
/>
)}

View File

@@ -11,6 +11,7 @@ const Wrapper = styled.div`
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
vertical-align: middle;
&:nth-child(1),
&:nth-child(4) {
@@ -58,8 +59,8 @@ const Wrapper = styled.div`
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
vertical-align: middle;
margin: 0;
}
`;

View File

@@ -1,76 +1,82 @@
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addFolderHeader, updateFolderHeader, deleteFolderHeader, setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = folder.draft ? get(folder, 'draft.request.headers', []) : get(folder, 'root.request.headers', []);
const headers = folder.draft
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setFolderHeaders({ collectionUid: collection.uid, folderUid: folder.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
addFolderHeader({
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
const handleHeadersChange = useCallback((updatedHeaders) => {
dispatch(setFolderHeaders({
collectionUid: collection.uid,
folderUid: folder.uid,
headers: updatedHeaders
}));
}, [dispatch, collection.uid, folder.uid]);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const handleHeaderValueChange = (e, _header, type) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {
header.value = e.target.value;
break;
}
case 'enabled': {
header.enabled = e.target.checked;
break;
}
}
dispatch(
updateFolderHeader({
header: header,
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
const handleRemoveHeader = (header) => {
dispatch(
deleteFolderHeader({
headerUid: header.uid,
collectionUid: collection.uid,
folderUid: folder.uid
})
);
const columns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
collection={collection}
placeholder={isLastEmptyRow ? 'Name' : ''}
/>
)
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={onChange}
collection={collection}
item={folder}
autocomplete={MimeTypes}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
description: ''
};
if (isBulkEditMode) {
@@ -81,7 +87,7 @@ const Headers = ({ collection, folder }) => {
</div>
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onChange={handleHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
@@ -94,87 +100,17 @@ const Headers = ({ collection, folder }) => {
<div className="text-xs mb-4 text-muted">
Request headers that will be sent with every request inside this folder.
</div>
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{headers && headers.length
? headers.map((header) => {
return (
<tr key={header.uid}>
<td>
<SingleLineEditor
value={header.name}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'name'
)}
autocomplete={headerAutoCompleteList}
collection={collection}
/>
</td>
<td>
<SingleLineEditor
value={header.value}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'value'
)}
collection={collection}
item={folder}
autocomplete={MimeTypes}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={header.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<div className="flex justify-between mt-2">
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<EditableTable
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
@@ -183,4 +119,5 @@ const Headers = ({ collection, folder }) => {
</StyledWrapper>
);
};
export default Headers;

View File

@@ -8,7 +8,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
@@ -22,6 +22,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -1,160 +1,87 @@
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { addFolderVar, deleteFolderVar, updateFolderVar } from 'providers/ReduxStore/slices/collections/index';
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addVar = () => {
dispatch(
addFolderVar({
collectionUid: collection.uid,
folderUid: folder.uid,
type: varType
})
);
};
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const handleVarChange = (e, v, type) => {
const _var = cloneDeep(v);
switch (type) {
case 'name': {
const value = e.target.value;
if (variableNameRegex.test(value) === false) {
toast.error(
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
);
return;
}
const handleVarsChange = useCallback((updatedVars) => {
dispatch(setFolderVars({
collectionUid: collection.uid,
folderUid: folder.uid,
vars: updatedVars,
type: varType
}));
}, [dispatch, collection.uid, folder.uid, varType]);
_var.name = value;
break;
}
case 'value': {
_var.value = e.target.value;
break;
}
case 'enabled': {
_var.enabled = e.target.checked;
break;
}
const getRowError = useCallback((row, index, key) => {
if (key !== 'name') return null;
if (!row.name || row.name.trim() === '') return null;
if (!variableNameRegex.test(row.name)) {
return 'Variable contains invalid characters. Must only contain alphanumeric characters, "-", "_", "."';
}
dispatch(
updateFolderVar({
type: varType,
var: _var,
folderUid: folder.uid,
collectionUid: collection.uid
})
);
};
return null;
}, []);
const handleRemoveVar = (_var) => {
dispatch(
deleteFolderVar({
type: varType,
varUid: _var.uid,
folderUid: folder.uid,
collectionUid: collection.uid
})
);
const columns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '40%'
},
{
key: 'value',
name: varType === 'request' ? 'Value' : (
<div className="flex items-center">
<span>Expr</span>
<InfoTip content="You can write any valid JS expression here" infotipId={`folder-${varType}-var`} />
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
item={folder}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
...(varType === 'response' ? { local: false } : {})
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Name</td>
{varType === 'request' ? (
<td>
<div className="flex items-center">
<span>Value</span>
</div>
</td>
) : (
<td>
<div className="flex items-center">
<span>Expr</span>
<InfoTip content="You can write any valid JS expression here" infotipId="response-var" />
</div>
</td>
)}
<td></td>
</tr>
</thead>
<tbody>
{vars && vars.length
? vars.map((_var) => {
return (
<tr key={_var.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={_var.name}
className="mousetrap"
onChange={(e) => handleVarChange(e, _var, 'name')}
/>
</td>
<td>
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleVarChange(
{
target: {
value: newValue
}
},
_var,
'value'
)}
collection={collection}
item={folder}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={_var.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleVarChange(e, _var, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-var text-link pr-2 py-3 mt-2 select-none" onClick={addVar}>
+ Add
</button>
<EditableTable
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
/>
</StyledWrapper>
);
};
export default VarsTable;

View File

@@ -8,13 +8,19 @@ import { useDispatch } from 'react-redux';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
const requestVars = folder.draft ? get(folder, 'draft.request.vars.req', []) : get(folder, 'root.request.vars.req', []);
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Pre Request</div>
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
</div>
<div className="flex-1">
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save

View File

@@ -28,7 +28,8 @@ const FolderSettings = ({ collection, folder }) => {
const activeHeadersCount = headers.filter((header) => header.enabled).length;
const requestVars = folderRoot?.request?.vars?.req || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(folderRoot, 'request.auth.mode');
const hasAuth = auth && auth !== 'none';

View File

@@ -11,6 +11,7 @@ const Wrapper = styled.div`
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
vertical-align: middle;
&:nth-child(1),
&:nth-child(4) {
@@ -58,8 +59,8 @@ const Wrapper = styled.div`
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
vertical-align: middle;
margin: 0;
}
`;

View File

@@ -0,0 +1,16 @@
import React from 'react';
const IconBottombarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-bottombar ${className}`} {...rest}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<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" />
{!collapsed && (
<rect x="4.6" y="15.6" width="14.8" height="2.8" rx="0.8" fill="currentColor" />
)}
</svg>
);
};
export default IconBottombarToggle;

View File

@@ -88,7 +88,9 @@ const Modal = ({
return closeModal({ type: 'esc' });
}
case ENTER_KEY_CODE: {
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm) {
// Skip if a submit button is focused - let native button click handle it to avoid double-fire
const isSubmitButton = event.target?.type === 'submit';
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm && !isSubmitButton) {
return handleConfirm();
}
}

View File

@@ -1,122 +1,171 @@
import React from 'react';
import React, { useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { useDispatch } from 'react-redux';
import { addAssertion, updateAssertion, deleteAssertion } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import AssertionRow from './AssertionRow';
import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from './AssertionOperator';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
import { moveAssertion } from 'providers/ReduxStore/slices/collections/index';
const unaryOperators = [
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
'isTruthy',
'isFalsy',
'isJson',
'isNumber',
'isString',
'isBoolean',
'isArray'
];
const parseAssertionOperator = (str = '') => {
if (!str || typeof str !== 'string' || !str.length) {
return { operator: 'eq', value: str };
}
const operators = [
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
'contains', 'notContains', 'length', 'matches', 'notMatches',
'startsWith', 'endsWith', 'between', ...unaryOperators
];
const [operator, ...rest] = str.split(' ');
const value = rest.join(' ');
if (unaryOperators.includes(operator)) {
return { operator, value: '' };
}
if (operators.includes(operator)) {
return { operator, value };
}
return { operator: 'eq', value: str };
};
const isUnaryOperator = (operator) => unaryOperators.includes(operator);
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
const handleAddAssertion = () => {
dispatch(
addAssertion({
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleAssertionChange = (e, _assertion, type) => {
const assertion = cloneDeep(_assertion);
switch (type) {
case 'name': {
assertion.name = e.target.value;
break;
const handleAssertionsChange = useCallback((updatedAssertions) => {
dispatch(setRequestAssertions({
collectionUid: collection.uid,
itemUid: item.uid,
assertions: updatedAssertions
}));
}, [dispatch, collection.uid, item.uid]);
const handleAssertionDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveAssertion({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [dispatch, collection.uid, item.uid]);
const columns = [
{
key: 'name',
name: 'Expr',
isKeyField: true,
placeholder: 'Expr',
width: '30%'
},
{
key: 'operator',
name: 'Operator',
width: '120px',
getValue: (row) => parseAssertionOperator(row.value).operator,
render: ({ row, rowIndex, isLastEmptyRow }) => {
const { operator } = parseAssertionOperator(row.value);
const assertionValue = parseAssertionOperator(row.value).value;
const handleOperatorChange = (newOperator) => {
const currentAssertions = assertions || [];
const existingAssertion = currentAssertions.find((a) => a.uid === row.uid);
const newValue = isUnaryOperator(newOperator) ? newOperator : `${newOperator} ${assertionValue}`;
if (existingAssertion) {
const updatedAssertions = currentAssertions.map((assertion) => {
if (assertion.uid === row.uid) {
return {
...assertion,
value: newValue
};
}
return assertion;
});
handleAssertionsChange(updatedAssertions);
} else {
handleAssertionsChange([...currentAssertions, { ...row, value: newValue }]);
}
};
return (
<AssertionOperator
operator={operator}
onChange={handleOperatorChange}
/>
);
}
case 'value': {
assertion.value = e.target.value;
break;
}
case 'enabled': {
assertion.enabled = e.target.checked;
break;
},
{
key: 'value',
name: 'Value',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => {
const { operator, value: assertionValue } = parseAssertionOperator(value);
if (isUnaryOperator(operator)) {
return <input type="text" className="cursor-default" disabled />;
}
return (
<SingleLineEditor
value={assertionValue}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) => onChange(`${operator} ${newValue}`)}
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
);
}
}
dispatch(
updateAssertion({
assertion: assertion,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
];
const handleRemoveAssertion = (assertion) => {
dispatch(
deleteAssertion({
assertUid: assertion.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleAssertionDrag = ({ updateReorderedItem }) => {
dispatch(
moveAssertion({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
const defaultRow = {
name: '',
value: 'eq ',
operator: 'eq'
};
return (
<StyledWrapper className="w-full">
<Table
headers={[
{ name: 'Expr', accessor: 'expr', width: '30%' },
{ name: 'Operator', accessor: 'operator', width: '120px' },
{ name: 'Value', accessor: 'value', width: '30%' },
{ name: '', accessor: '', width: '15%' }
]}
>
<ReorderTable updateReorderedItem={handleAssertionDrag}>
{assertions && assertions.length
? assertions.map((assertion) => {
return (
<tr key={assertion.uid} data-uid={assertion.uid}>
<td className="flex relative">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={assertion.name}
className="mousetrap"
onChange={(e) => handleAssertionChange(e, assertion, 'name')}
/>
</td>
<AssertionRow
key={assertion.uid}
assertion={assertion}
item={item}
collection={collection}
handleAssertionChange={handleAssertionChange}
handleRemoveAssertion={handleRemoveAssertion}
onSave={onSave}
handleRun={handleRun}
/>
</tr>
);
})
: null}
</ReorderTable>
</Table>
<button className="btn-add-assertion text-link pr-2 py-3 mt-2 select-none" onClick={handleAddAssertion}>
+ Add Assertion
</button>
<EditableTable
columns={columns}
rows={assertions || []}
onChange={handleAssertionsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleAssertionDrag}
/>
</StyledWrapper>
);
};
export default Assertions;

View File

@@ -1,153 +1,86 @@
import React from 'react';
import React, { useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addFormUrlEncodedParam,
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam,
moveFormUrlEncodedParam
moveFormUrlEncodedParam,
setFormUrlEncodedParams
} from 'providers/ReduxStore/slices/collections';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import ReorderTable from 'components/ReorderTable/index';
import Table from 'components/Table/index';
const FormUrlEncodedParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded');
const addParam = () => {
dispatch(
addFormUrlEncodedParam({
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleParamChange = (e, _param, type) => {
const param = cloneDeep(_param);
switch (type) {
case 'name': {
param.name = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
break;
}
const handleParamsChange = useCallback((updatedParams) => {
dispatch(setFormUrlEncodedParams({
collectionUid: collection.uid,
itemUid: item.uid,
params: updatedParams
}));
}, [dispatch, collection.uid, item.uid]);
const handleParamDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveFormUrlEncodedParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [dispatch, collection.uid, item.uid]);
const columns = [
{
key: 'name',
name: 'Key',
isKeyField: true,
placeholder: 'Key',
width: '30%'
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
allowNewlines={true}
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
dispatch(
updateFormUrlEncodedParam({
param: param,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
];
const handleRemoveParams = (param) => {
dispatch(
deleteFormUrlEncodedParam({
paramUid: param.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleParamDrag = ({ updateReorderedItem }) => {
dispatch(
moveFormUrlEncodedParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
const defaultRow = {
name: '',
value: '',
description: ''
};
return (
<StyledWrapper className="w-full">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '40%' },
{ name: 'Value', accessor: 'value', width: '46%' },
{ name: '', accessor: '', width: '14%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
/>
</td>
<td>
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)}
allowNewlines={true}
onRun={handleRun}
collection={collection}
item={item}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={addParam}>
+ Add Param
</button>
<EditableTable
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleParamDrag}
/>
</StyledWrapper>
);
};
export default FormUrlEncodedParams;

View File

@@ -1,30 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import find from 'lodash/find';
import get from 'lodash/get';
import classnames from 'classnames';
@@ -15,54 +15,86 @@ import Tests from 'components/RequestPane/Tests';
import { useTheme } from 'providers/Theme';
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
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';
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
const MULTIPLE_CONTENT_TABS = new Set(['script', 'vars', 'auth', 'docs']);
const TAB_CONFIG = [
{ key: 'query', label: 'Query' },
{ key: 'variables', label: 'Variables' },
{ key: 'headers', label: 'Headers' },
{ key: 'auth', label: 'Auth' },
{ key: 'vars', label: 'Vars' },
{ key: 'script', label: 'Script' },
{ key: 'assert', label: 'Assert' },
{ key: 'tests', label: 'Tests' },
{ key: 'docs', label: 'Docs' },
{ key: 'settings', label: 'Settings' }
];
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const preferences = useSelector((state) => state.app.preferences);
const query = item.draft
? get(item, 'draft.request.body.graphql.query', '')
: get(item, 'request.body.graphql.query', '');
const variables = item.draft
? get(item, 'draft.request.body.graphql.variables')
: get(item, 'request.body.graphql.variables');
const { displayedTheme } = useTheme();
const [schema, setSchema] = useState(null);
const preferences = useSelector((state) => state.app.preferences);
const schemaActionsRef = useRef(null);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
useEffect(() => {
onSchemaLoad(schema);
}, [schema]);
}, [schema, onSchemaLoad]);
const onQueryChange = (value) => {
dispatch(
updateRequestGraphqlQuery({
query: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onQueryChange = useCallback(
(value) => {
dispatch(
updateRequestGraphqlQuery({
query: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
},
[dispatch, item.uid, collection.uid]
);
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
const onRun = useCallback(
() => dispatch(sendRequest(item, collection.uid)),
[dispatch, item, collection.uid]
);
const getTabPanel = (tab) => {
switch (tab) {
case 'query': {
const onSave = useCallback(
() => dispatch(saveRequest(item.uid, collection.uid)),
[dispatch, item.uid, collection.uid]
);
const selectTab = useCallback(
(tabKey) => {
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: tabKey }));
},
[dispatch, item.uid]
);
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
const tabPanel = useMemo(() => {
switch (requestPaneTab) {
case 'query':
return (
<QueryEditor
collection={collection}
@@ -77,94 +109,55 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
fontSize={get(preferences, 'font.codeFontSize')}
/>
);
}
case 'variables': {
case 'variables':
return <GraphQLVariables item={item} variables={variables} collection={collection} />;
}
case 'headers': {
case 'headers':
return <RequestHeaders item={item} collection={collection} />;
}
case 'auth': {
case 'auth':
return <Auth item={item} collection={collection} />;
}
case 'vars': {
case 'vars':
return <Vars item={item} collection={collection} />;
}
case 'assert': {
case 'assert':
return <Assertions item={item} collection={collection} />;
}
case 'script': {
case 'script':
return <Script item={item} collection={collection} />;
}
case 'tests': {
case 'tests':
return <Tests item={item} collection={collection} />;
}
case 'docs': {
case 'docs':
return <Documentation item={item} collection={collection} />;
}
case 'settings': {
case 'settings':
return <Settings item={item} collection={collection} />;
}
default: {
default:
return <div className="mt-4">404 | Not found</div>;
}
}
};
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]);
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const rightContent = (
<div ref={schemaActionsRef}>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
);
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('query')} role="tab" onClick={() => selectTab('query')}>
Query
</div>
<div className={getTabClassname('variables')} role="tab" onClick={() => selectTab('variables')}>
Variables
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
</div>
<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">
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
<div className="flex flex-col h-full relative">
<RequestPaneTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={schemaActionsRef}
/>
<section className={classnames('flex w-full flex-1', { 'mt-5': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
</section>
</StyledWrapper>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.3rem;
height: 2.1rem;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,6 +20,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { find, get } from 'lodash';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryParams from 'components/RequestPane/QueryParams';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
@@ -11,175 +12,144 @@ import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
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';
import Documentation from 'components/Documentation/index';
import StatusDot from 'components/StatusDot';
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const MULTIPLE_CONTENT_TABS = new Set(['params', 'script', 'vars', 'auth', 'docs']);
const TAB_CONFIG = [
{ key: 'params', label: 'Params' },
{ key: 'body', label: 'Body' },
{ key: 'headers', label: 'Headers' },
{ key: 'auth', label: 'Auth' },
{ key: 'vars', label: 'Vars' },
{ key: 'script', label: 'Script' },
{ key: 'assert', label: 'Assert' },
{ key: 'tests', label: 'Tests' },
{ key: 'docs', label: 'Docs' },
{ key: 'settings', label: 'Settings' }
];
const TAB_PANELS = {
params: QueryParams,
body: RequestBody,
headers: RequestHeaders,
auth: Auth,
vars: Vars,
assert: Assertions,
script: Script,
tests: Tests,
docs: Documentation,
settings: Settings
};
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
const getTabPanel = (tab) => {
switch (tab) {
case 'params': {
return <QueryParams item={item} collection={collection} />;
}
case 'body': {
return <RequestBody item={item} collection={collection} />;
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
case 'auth': {
return <Auth item={item} collection={collection} />;
}
case 'vars': {
return <Vars item={item} collection={collection} />;
}
case 'assert': {
return <Assertions item={item} collection={collection} />;
}
case 'script': {
return <Script item={item} collection={collection} />;
}
case 'tests': {
return <Tests item={item} collection={collection} />;
}
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>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const bodyModeRef = useRef(null);
const initialAutoSelectDone = useRef(false);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
const requestPaneTab = focusedTab?.requestPaneTab;
const getProperty = useCallback(
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
[item.draft, item]
);
const params = getProperty('request.params');
const body = getProperty('request.body');
const headers = getProperty('request.headers');
const script = getProperty('request.script');
const assertions = getProperty('request.assertions');
const tests = getProperty('request.tests');
const docs = getProperty('request.docs');
const requestVars = getProperty('request.vars.req');
const responseVars = getProperty('request.vars.res');
const auth = getProperty('request.auth');
const tags = getProperty('tags');
const activeCounts = useMemo(() => ({
params: params.filter((p) => p.enabled).length,
headers: headers.filter((h) => h.enabled).length,
assertions: assertions.filter((a) => a.enabled).length,
vars: requestVars.filter((r) => r.enabled).length + responseVars.filter((r) => r.enabled).length
}), [params, headers, assertions, requestVars, responseVars]);
const selectTab = useCallback(
(tabKey) => {
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: tabKey }));
},
[dispatch, item.uid]
);
const indicators = useMemo(() => {
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
const hasTestError = item.testScriptErrorMessage;
return {
params: activeCounts.params > 0 ? <sup className="font-medium">{activeCounts.params}</sup> : null,
body: body.mode !== 'none' ? <StatusDot /> : null,
headers: activeCounts.headers > 0 ? <sup className="font-medium">{activeCounts.headers}</sup> : null,
auth: auth.mode !== 'none' ? <StatusDot /> : null,
vars: activeCounts.vars > 0 ? <sup className="font-medium">{activeCounts.vars}</sup> : null,
script: (script.req || script.res) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
tests: tests?.length > 0 ? (hasTestError ? <StatusDot type="error" /> : <StatusDot />) : null,
docs: docs?.length > 0 ? <StatusDot /> : null,
settings: tags?.length > 0 ? <StatusDot /> : null
};
}, [activeCounts, body.mode, auth.mode, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
const allTabs = useMemo(
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
[indicators]
);
const tabPanel = useMemo(() => {
const Component = TAB_PANELS[requestPaneTab];
return Component ? <Component item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
}, [requestPaneTab, item, collection]);
useEffect(() => {
if (!initialAutoSelectDone.current && activeCounts.params === 0 && body.mode !== 'none') {
selectTab('body');
}
initialAutoSelectDone.current = true;
}, [activeCounts.params, body.mode, selectTab]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const isMultipleContentTab = ['params', 'script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
// 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 params = getPropertyFromDraftOrRequest('request.params');
const body = getPropertyFromDraftOrRequest('request.body');
const headers = getPropertyFromDraftOrRequest('request.headers');
const script = getPropertyFromDraftOrRequest('request.script');
const assertions = getPropertyFromDraftOrRequest('request.assertions');
const tests = getPropertyFromDraftOrRequest('request.tests');
const docs = getPropertyFromDraftOrRequest('request.docs');
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
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;
const activeAssertionsLength = assertions.filter((assertion) => assertion.enabled).length;
const activeVarsLength = requestVars.filter((request) => request.enabled).length;
useEffect(() => {
if (activeParamsLength === 0 && body.mode !== 'none') {
selectTab('body');
}
}, []);
const rightContent = requestPaneTab === 'body' ? (
<div ref={bodyModeRef}>
<RequestBodyMode item={item} collection={collection} />
</div>
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}>
Params
{activeParamsLength > 0 && <sup className="ml-1 font-medium">{activeParamsLength}</sup>}
</div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Body
{body.mode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
{activeVarsLength > 0 && <sup className="ml-1 font-medium">{activeVarsLength}</sup>}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
{(script.req || script.res) && (
item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage
? <StatusDot type="error" />
: <StatusDot />
)}
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
{activeAssertionsLength > 0 && <sup className="ml-1 font-medium">{activeAssertionsLength}</sup>}
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
{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 && <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">
<RequestBodyMode item={item} collection={collection} />
</div>
) : null}
</div>
<section
className={classnames('flex w-full flex-1', {
'mt-5': !isMultipleContentTab
})}
>
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
</HeightBoundContainer>
<div className="flex flex-col h-full relative">
<RequestPaneTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={bodyModeRef}
delayedTabs={['body']}
/>
<section className={classnames('flex w-full flex-1', { 'mt-3': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
</section>
</StyledWrapper>
</div>
);
};

View File

@@ -1,48 +1,41 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 500;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: ${(props) => props.theme.font.size.base};
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
.upload-btn,
.clear-file-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease;
&:focus {
outline: none !important;
border: solid 1px transparent;
&:hover {
color: ${(props) => props.theme.colors.text.link};
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
.clear-file-btn:hover {
color: ${(props) => props.theme.colors.text.danger};
}
.file-value-cell {
padding: 4px 0;
.file-name {
font-size: 12px;
color: ${(props) => props.theme.text};
}
}
.value-cell {
.flex-1 {
min-width: 0;
}
}
`;

View File

@@ -1,216 +1,216 @@
import React from 'react';
import React, { useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconUpload, IconX, IconFile } from '@tabler/icons';
import {
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
moveMultipartFormParam
moveMultipartFormParam,
setMultipartFormParams
} from 'providers/ReduxStore/slices/collections';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
import path from 'utils/common/path';
import { isWindowsOS } from 'utils/common/platform';
const MultipartFormParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm');
const addParam = () => {
dispatch(
addMultipartFormParam({
itemUid: item.uid,
collectionUid: collection.uid,
type: 'text',
value: ''
})
);
};
const addFile = () => {
dispatch(
addMultipartFormParam({
itemUid: item.uid,
collectionUid: collection.uid,
type: 'file',
value: []
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleParamChange = (e, _param, type) => {
const param = cloneDeep(_param);
switch (type) {
case 'name': {
param.name = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
break;
}
case 'contentType': {
param.contentType = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
break;
const handleParamsChange = useCallback((updatedParams) => {
dispatch(setMultipartFormParams({
collectionUid: collection.uid,
itemUid: item.uid,
params: updatedParams
}));
}, [dispatch, collection.uid, item.uid]);
const handleParamDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveMultipartFormParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [dispatch, collection.uid, item.uid]);
const handleBrowseFiles = useCallback((row, onChange) => {
dispatch(browseFiles())
.then((filePaths) => {
const processedPaths = filePaths.map((filePath) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
});
const currentParams = item.draft
? get(item, 'draft.request.body.multipartForm')
: get(item, 'request.body.multipartForm');
const updatedParams = (currentParams || []).map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'file', value: processedPaths };
}
return p;
});
handleParamsChange(updatedParams);
})
.catch((error) => {
console.error(error);
});
}, [dispatch, collection.pathname, item, handleParamsChange]);
const handleClearFile = useCallback((row) => {
const currentParams = params || [];
const updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'text', value: '' };
}
return p;
});
handleParamsChange(updatedParams);
}, [params, handleParamsChange]);
const handleValueChange = useCallback((row, newValue, onChange) => {
const currentParams = params || [];
const existingParam = currentParams.find((p) => p.uid === row.uid);
if (existingParam) {
const updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'text', value: newValue };
}
return p;
});
handleParamsChange(updatedParams);
} else {
onChange(newValue);
}
dispatch(
updateMultipartFormParam({
param: param,
itemUid: item.uid,
collectionUid: collection.uid
})
);
}, [params, handleParamsChange]);
const getFileName = (filePaths) => {
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
return null;
}
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
const validPaths = paths.filter((v) => v != null && v !== '');
if (validPaths.length === 0) return null;
const separator = isWindowsOS() ? '\\' : '/';
if (validPaths.length === 1) {
return validPaths[0].split(separator).pop();
}
return `${validPaths.length} file(s)`;
};
const handleRemoveParams = (param) => {
dispatch(
deleteMultipartFormParam({
paramUid: param.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const columns = [
{
key: 'name',
name: 'Key',
isKeyField: true,
placeholder: 'Key',
width: '30%'
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
width: '35%',
render: ({ row, value, onChange, isLastEmptyRow }) => {
const isFile = row.type === 'file';
const fileName = isFile ? getFileName(value) : null;
const hasTextValue = !isFile && value && value.length > 0;
const handleParamDrag = ({ updateReorderedItem }) => {
dispatch(
moveMultipartFormParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
if (fileName) {
return (
<div className="flex items-center file-value-cell">
<IconFile size={16} className="text-muted mr-1" />
<span className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
{fileName}
</span>
<button
className="clear-file-btn ml-1"
onClick={() => handleClearFile(row)}
title="Remove file"
>
<IconX size={16} />
</button>
</div>
);
}
return (
<div className="flex items-center value-cell">
<div className="flex-1">
<MultiLineEditor
onSave={onSave}
theme={storedTheme}
value={value || ''}
onChange={(newValue) => handleValueChange(row, newValue, onChange)}
onRun={handleRun}
allowNewlines={true}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
</div>
{!hasTextValue && !isLastEmptyRow && (
<button
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
>
<IconUpload size={16} />
</button>
)}
</div>
);
}
},
{
key: 'contentType',
name: 'Content-Type',
placeholder: 'Auto',
width: '20%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
onSave={onSave}
theme={storedTheme}
placeholder={isLastEmptyRow ? 'Auto' : ''}
value={value || ''}
onChange={onChange}
onRun={handleRun}
collection={collection}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
contentType: '',
type: 'text'
};
return (
<StyledWrapper className="w-full">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '29%' },
{ name: 'Value', accessor: 'value', width: '29%' },
{ name: 'Content-Type', accessor: 'content-type', width: '28%' },
{ name: '', accessor: '', width: '14%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} className="w-full" data-uid={param.uid}>
<td className="flex relative">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
/>
</td>
<td>
{param.type === 'file' ? (
<FilePickerEditor
value={param.value}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)}
collection={collection}
/>
) : (
<MultiLineEditor
onSave={onSave}
theme={storedTheme}
value={param.value}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)}
onRun={handleRun}
allowNewlines={true}
collection={collection}
item={item}
/>
)}
</td>
<td>
<MultiLineEditor
onSave={onSave}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'contentType'
)}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
<div>
<button className="btn-add-param text-link pr-2 pt-3 mt-2 select-none" onClick={addParam}>
+ Add Param
</button>
</div>
<div>
<button className="btn-add-param text-link pr-2 pt-3 select-none" onClick={addFile}>
+ Add File
</button>
</div>
<EditableTable
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleParamDrag}
/>
</StyledWrapper>
);
};
export default MultipartFormParams;

View File

@@ -1,24 +1,17 @@
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import InfoTip from 'components/InfoTip';
import { IconTrash } from '@tabler/icons';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addQueryParam,
updateQueryParam,
deleteQueryParam,
moveQueryParam,
updatePathParam,
setQueryParams
} from 'providers/ReduxStore/slices/collections';
import MultiLineEditor from 'components/MultiLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable';
import BulkEditor from '../../BulkEditor';
const QueryParams = ({ item, collection }) => {
@@ -30,100 +23,100 @@ const QueryParams = ({ item, collection }) => {
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const handleAddQueryParam = () => {
dispatch(
addQueryParam({
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleQueryParamChange = (e, data, key) => {
let value;
const handleQueryParamsChange = useCallback((updatedParams) => {
const paramsWithType = updatedParams.map((p) => ({ ...p, type: 'query' }));
dispatch(setQueryParams({
collectionUid: collection.uid,
itemUid: item.uid,
params: paramsWithType
}));
}, [dispatch, collection.uid, item.uid]);
switch (key) {
case 'name': {
value = e.target.value;
break;
}
case 'value': {
value = e.target.value;
break;
}
case 'enabled': {
value = e.target.checked;
break;
}
}
let queryParam = cloneDeep(data);
if (queryParam[key] === value) {
return;
}
queryParam[key] = value;
dispatch(
updateQueryParam({
queryParam,
const handlePathParamChange = useCallback((rowUid, key, value) => {
const pathParam = pathParams.find((p) => p.uid === rowUid);
if (pathParam) {
dispatch(updatePathParam({
pathParam: { ...pathParam, [key]: value },
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handlePathParamChange = (e, data) => {
let value = e.target.value;
let pathParam = cloneDeep(data);
if (pathParam['value'] === value) {
return;
}));
}
}, [dispatch, pathParams, item.uid, collection.uid]);
pathParam['value'] = value;
dispatch(
updatePathParam({
pathParam,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveQueryParam = (param) => {
dispatch(
deleteQueryParam({
paramUid: param.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleQueryParamDrag = ({ updateReorderedItem }) => {
dispatch(
moveQueryParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
};
const handleQueryParamDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveQueryParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [dispatch, collection.uid, item.uid]);
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 }));
const queryColumns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '30%'
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
onRun={handleRun}
collection={collection}
item={item}
variablesAutocomplete={true}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const pathColumns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
width: '30%',
readOnly: true
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) => handlePathParamChange(row.uid, 'value', newValue)}
onRun={handleRun}
collection={collection}
item={item}
/>
)
}
];
const defaultQueryRow = {
name: '',
value: '',
description: '',
type: 'query'
};
if (isBulkEditMode) {
@@ -131,7 +124,7 @@ const QueryParams = ({ item, collection }) => {
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={queryParams}
onChange={handleBulkParamsChange}
onChange={handleQueryParamsChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
@@ -144,69 +137,20 @@ const QueryParams = ({ item, collection }) => {
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '31%' },
{ name: 'Value', accessor: 'path', width: '56%' },
{ name: '', accessor: '', width: '13%' }
]}
>
<ReorderTable updateReorderedItem={handleQueryParamDrag}>
{queryParams && queryParams.length
? queryParams.map((param, index) => (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleQueryParamChange(e, param, 'name')}
/>
</td>
<td>
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
onRun={handleRun}
collection={collection}
item={item}
variablesAutocomplete={true}
/>
</td>
<td>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleQueryParamChange(e, param, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveQueryParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
))
: null}
</ReorderTable>
</Table>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<EditableTable
columns={queryColumns}
rows={queryParams || []}
onChange={handleQueryParamsChange}
defaultRow={defaultQueryRow}
reorderable={true}
onReorder={handleQueryParamDrag}
/>
<div className="flex justify-end mt-2">
<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">
@@ -220,58 +164,22 @@ const QueryParams = ({ item, collection }) => {
</div>
</InfoTip>
</div>
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{pathParams && pathParams.length
? pathParams.map((path, index) => {
return (
<tr key={path.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={path.name}
className="mousetrap"
readOnly={true}
/>
</td>
<td>
<MultiLineEditor
value={path.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handlePathParamChange(
{
target: {
value: newValue
}
},
path
)}
onRun={handleRun}
collection={collection}
item={item}
/>
</td>
</tr>
);
})
: null}
</tbody>
</table>
{!(pathParams && pathParams.length) ? <div className="title pr-2 py-3 mt-2 text-xs"></div> : null}
{pathParams && pathParams.length > 0 ? (
<EditableTable
columns={pathColumns}
rows={pathParams}
onChange={() => {}}
defaultRow={{}}
showCheckbox={false}
showDelete={false}
showAddRow={false}
/>
) : (
<div className="title pr-2 py-3 mt-2 text-xs"></div>
)}
</div>
</StyledWrapper>
);
};
export default QueryParams;

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.3rem;
height: 2.1rem;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};

View File

@@ -1,8 +1,19 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import {
requestUrlChanged,
updateRequestMethod,
setRequestHeaders,
updateRequestBodyMode,
updateRequestBody,
updateRequestGraphqlQuery,
updateRequestGraphqlVariables,
updateRequestAuthMode,
updateAuth
} from 'providers/ReduxStore/slices/collections';
import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
import { getRequestFromCurlCommand } from 'utils/curl';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
@@ -81,12 +92,289 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}
};
const handleGraphqlPaste = useCallback((event) => {
if (item.type !== 'graphql-request') {
return;
}
const clipboardData = event.clipboardData || window.clipboardData;
const pastedData = clipboardData.getData('Text');
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
toast.error('Invalid cURL command');
return;
}
event.preventDefault();
try {
const request = getRequestFromCurlCommand(pastedData, 'graphql-request');
if (!request || !request.url) {
toast.error('Invalid cURL command');
return;
}
// Update URL
dispatch(requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
url: request.url
}));
// Update method
dispatch(updateRequestMethod({
method: request.method.toUpperCase(), // Convert to uppercase
itemUid: item.uid,
collectionUid: collection.uid
}));
// Update headers
if (request.headers && request.headers.length > 0) {
dispatch(setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: request.headers
}));
}
// Update body
if (request.body) {
const bodyMode = request.body.mode;
if (bodyMode === 'graphql') {
dispatch(updateRequestGraphqlQuery({
itemUid: item.uid,
collectionUid: collection.uid,
query: request.body.graphql.query
}));
let variables = request.body.graphql.variables;
try {
variables = JSON.parse(variables);
} catch (error) {
// Keep variables as-is if JSON parsing fails
}
dispatch(updateRequestGraphqlVariables({
itemUid: item.uid,
collectionUid: collection.uid,
variables: variables
}));
}
toast.success('GraphQL query imported successfully');
}
} catch (error) {
console.error('Error parsing cURL command:', error);
toast.error('Failed to parse GraphQL query');
}
}, [dispatch, item.uid, collection.uid]);
const handleHttpPaste = useCallback((event) => {
// Only enable curl paste detection for HTTP requests
if (item.type !== 'http-request') {
return;
}
const clipboardData = event.clipboardData || window.clipboardData;
const pastedData = clipboardData.getData('Text');
// Check if pasted data looks like a cURL command
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
// Not a curl command, allow normal paste behavior
return;
}
// Prevent the default paste behavior
event.preventDefault();
try {
// Parse the curl command
const request = getRequestFromCurlCommand(pastedData);
if (!request || !request.url) {
toast.error('Invalid cURL command');
return;
}
// Update URL
dispatch(
requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
url: request.url
})
);
// Update method
if (request.method) {
dispatch(
updateRequestMethod({
method: request.method.toUpperCase(), // Convert to uppercase
itemUid: item.uid,
collectionUid: collection.uid
})
);
}
// Update headers
if (request.headers && request.headers.length > 0) {
dispatch(
setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: request.headers
})
);
}
// Update body
if (request.body) {
const bodyMode = request.body.mode;
// Set body mode first
dispatch(
updateRequestBodyMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: bodyMode
})
);
// Set body content based on mode
if (bodyMode === 'json' && request.body.json) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.json
})
);
} else if (bodyMode === 'text' && request.body.text) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.text
})
);
} else if (bodyMode === 'xml' && request.body.xml) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.xml
})
);
} else if (bodyMode === 'graphql' && request.body.graphql) {
if (request.body.graphql.query) {
dispatch(
updateRequestGraphqlQuery({
itemUid: item.uid,
collectionUid: collection.uid,
query: request.body.graphql.query
})
);
}
if (request.body.graphql.variables) {
dispatch(
updateRequestGraphqlVariables({
itemUid: item.uid,
collectionUid: collection.uid,
variables: request.body.graphql.variables
})
);
}
} else if (bodyMode === 'formUrlEncoded' && request.body.formUrlEncoded) {
// For formUrlEncoded, we need to set each param individually
// This is a limitation - we'd need to clear existing params first
// For now, we'll set the body mode and the user can manually adjust
// TODO: Implement proper formUrlEncoded param setting
} else if (bodyMode === 'multipartForm' && request.body.multipartForm) {
// For multipartForm, similar limitation
// TODO: Implement proper multipartForm param setting
}
}
// Update auth
if (request.auth) {
const authMode = request.auth.mode;
if (authMode) {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: authMode
})
);
// Set auth content based on mode
if (request.auth.basic) {
dispatch(
updateAuth({
mode: 'basic',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.basic
})
);
} else if (request.auth.bearer) {
dispatch(
updateAuth({
mode: 'bearer',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.bearer
})
);
} else if (request.auth.digest) {
dispatch(
updateAuth({
mode: 'digest',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.digest
})
);
} else if (request.auth.ntlm) {
dispatch(
updateAuth({
mode: 'ntlm',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.ntlm
})
);
} else if (request.auth.awsv4) {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.awsv4
})
);
} else if (request.auth.apikey) {
dispatch(
updateAuth({
mode: 'apikey',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.apikey
})
);
}
}
}
toast.success('cURL command imported successfully');
} catch (error) {
console.error('Error parsing cURL command:', error);
toast.error('Failed to parse cURL command');
}
},
[dispatch, item.uid, item.type, collection.uid]
);
const handleCancelRequest = (e) => {
e.preventDefault();
e.stopPropagation();
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="flex items-center">
<div className="flex flex-1 items-center h-full method-selector-container">
@@ -110,10 +398,12 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<SingleLineEditor
ref={editorRef}
value={url}
placeholder="Enter URL or paste a cURL request"
onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
collection={collection}
highlightPathParams={true}
item={item}
@@ -127,7 +417,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
@@ -142,7 +432,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconDeviceFloppy
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
@@ -153,7 +443,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={22}
size={20}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
@@ -161,7 +451,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={22}
size={20}
data-testid="send-arrow-icon"
/>
)}

View File

@@ -2,6 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
min-width: 125px;
.body-mode-selector {
background: transparent;

View File

@@ -1,17 +1,14 @@
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
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);
@@ -20,74 +17,76 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
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(
addRequestHeader({
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleHeaderValueChange = (e, _header, type) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {
header.value = e.target.value;
break;
}
case 'enabled': {
header.enabled = e.target.checked;
break;
}
}
const handleHeadersChange = useCallback((updatedHeaders) => {
dispatch(setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: updatedHeaders
}));
}, [dispatch, collection.uid, item.uid]);
dispatch(
updateRequestHeader({
header: header,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveHeader = (header) => {
dispatch(
deleteRequestHeader({
headerUid: header.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleHeaderDrag = ({ updateReorderedItem }) => {
dispatch(
moveRequestHeader({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
};
const handleHeaderDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveRequestHeader({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [dispatch, collection.uid, item.uid]);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
const columns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Name' : ''}
/>
)
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
onRun={handleRun}
autocomplete={MimeTypes}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
description: ''
};
if (isBulkEditMode) {
@@ -95,7 +94,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onChange={handleHeadersChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
@@ -106,83 +105,15 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
return (
<StyledWrapper className="w-full">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '34%' },
{ name: 'Value', accessor: 'value', width: '46%' },
{ name: '', accessor: '', width: '20%' }
]}
>
<ReorderTable updateReorderedItem={handleHeaderDrag}>
{headers && headers.length
? headers.map((header) => {
return (
<tr key={header.uid} data-uid={header.uid}>
<td className="flex relative">
<SingleLineEditor
value={header.name}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'name'
)}
autocomplete={headerAutoCompleteList}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<SingleLineEditor
value={header.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'value'
)}
onRun={handleRun}
autocomplete={MimeTypes}
collection={collection}
item={item}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={header.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
+ {addHeaderText || 'Add Header'}
</button>
<EditableTable
columns={columns}
rows={headers || []}
onChange={handleHeadersChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleHeaderDrag}
/>
<div className="flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
@@ -190,4 +121,5 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
</StyledWrapper>
);
};
export default RequestHeaders;

View File

@@ -1,14 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
&.tabs {
div.more-tabs {
color: var(--color-tab-inactive) !important;
border-bottom: solid 2px transparent;
}
div.tab {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
white-space: nowrap;
vertical-align: middle;
flex-shrink: 0;
&:focus,
&:active,
@@ -20,12 +31,21 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
.content-indicator {
color: ${(props) => props.theme.text}
color: ${(props) => props.theme.text};
}
sup {
display: inline-flex;
align-items: center;
line-height: 1;
vertical-align: baseline;
margin-left: 0;
}
}
}

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import { IconChevronDown } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const DROPDOWN_WIDTH = 60;
const CALCULATION_DELAY_DEFAULT = 20;
const CALCULATION_DELAY_EXTENDED = 150;
const RequestPaneTabs = ({
tabs,
activeTab,
onTabSelect,
rightContent,
rightContentRef,
delayedTabs = []
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
const tabsContainerRef = useRef(null);
const tabRefsMap = useRef({});
const dropdownTippyRef = useRef(null);
const handleTabSelect = useCallback(
(tabKey) => {
onTabSelect(tabKey);
dropdownTippyRef.current?.hide();
},
[onTabSelect]
);
const calculateTabVisibility = useCallback(() => {
const container = tabsContainerRef.current;
if (!container || !tabs.length) return;
const containerWidth = container.offsetWidth;
const rightContentWidth = rightContentRef?.current
? rightContentRef.current.offsetWidth + 20
: 0;
const availableWidth = containerWidth - rightContentWidth - DROPDOWN_WIDTH;
const visible = [];
const overflow = [];
let currentWidth = 0;
for (const tab of tabs) {
const tabElement = tabRefsMap.current[tab.key];
const tabWidth = tabElement ? tabElement.offsetWidth + 20 : 100;
if (currentWidth + tabWidth <= availableWidth && !overflow.length) {
visible.push(tab);
currentWidth += tabWidth;
} else {
overflow.push(tab);
}
}
if (!visible.some((t) => t.key === activeTab) && overflow.length) {
const activeTabIndex = overflow.findIndex((t) => t.key === activeTab);
if (activeTabIndex !== -1) {
const [activeTabItem] = overflow.splice(activeTabIndex, 1);
const lastVisible = visible.pop();
if (lastVisible) overflow.unshift(lastVisible);
visible.push(activeTabItem);
}
}
setVisibleTabs(visible);
setOverflowTabs(overflow);
}, [tabs, activeTab, rightContentRef]);
const renderTab = useCallback(
(tab, isInDropdown = false) => {
const isActive = tab.key === activeTab;
if (isInDropdown) {
return (
<div
key={tab.key}
className={classnames('dropdown-item', { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
>
<span className="flex items-center gap-1">
{tab.label}
{tab.indicator}
</span>
</div>
);
}
return (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
);
},
[activeTab, handleTabSelect]
);
useEffect(() => {
const delay = delayedTabs.includes(activeTab) ? CALCULATION_DELAY_EXTENDED : CALCULATION_DELAY_DEFAULT;
const timeoutId = setTimeout(() => requestAnimationFrame(calculateTabVisibility), delay);
return () => clearTimeout(timeoutId);
}, [calculateTabVisibility, activeTab, delayedTabs]);
useEffect(() => {
let frameId = null;
const observer = new ResizeObserver(() => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(calculateTabVisibility);
});
if (tabsContainerRef.current) observer.observe(tabsContainerRef.current);
if (rightContentRef?.current) observer.observe(rightContentRef.current);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [calculateTabVisibility, rightContentRef]);
const hiddenStyle = useMemo(
() => ({ visibility: 'hidden', position: 'absolute', display: 'flex', pointerEvents: 'none' }),
[]
);
return (
<StyledWrapper ref={tabsContainerRef} className="flex items-center tabs" role="tablist">
<div style={hiddenStyle}>
{tabs.map((tab) => (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: tab.key === activeTab })}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
))}
</div>
{visibleTabs.map((tab) => renderTab(tab))}
{overflowTabs.length > 0 && (
<Dropdown
icon={(
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
<span>More</span>
<IconChevronDown size={14} strokeWidth={2} />
</div>
)}
placement="bottom-start"
onCreate={(instance) => (dropdownTippyRef.current = instance)}
>
<div style={{ minWidth: '150px' }}>{overflowTabs.map((tab) => renderTab(tab, true))}</div>
</Dropdown>
)}
{rightContent && (
<div className="flex flex-grow justify-end items-center">
{rightContent}
</div>
)}
</StyledWrapper>
);
};
export default RequestPaneTabs;

View File

@@ -2,6 +2,7 @@ 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 { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import TagList from 'components/TagList/index';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
@@ -26,6 +27,7 @@ const Tags = ({ item, collection }) => {
collectionUid: collection.uid
})
);
dispatch(makeTabPermanent({ uid: item.uid }));
}
}, [dispatch, tags, item.uid, collection.uid]);
@@ -37,6 +39,7 @@ const Tags = ({ item, collection }) => {
collectionUid: collection.uid
})
);
dispatch(makeTabPermanent({ uid: item.uid }));
}, [dispatch, item.uid, collection.uid]);
const handleRequestSave = () => {

View File

@@ -1,170 +1,100 @@
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addVar, updateVar, deleteVar, moveVar } from 'providers/ReduxStore/slices/collections';
import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
const VarsTable = ({ item, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const handleAddVar = () => {
dispatch(
addVar({
type: varType,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleVarChange = (e, v, type) => {
const _var = cloneDeep(v);
switch (type) {
case 'name': {
const value = e.target.value;
if (variableNameRegex.test(value) === false) {
toast.error(
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
);
return;
}
const handleVarsChange = useCallback((updatedVars) => {
dispatch(setRequestVars({
collectionUid: collection.uid,
itemUid: item.uid,
vars: updatedVars,
type: varType
}));
}, [dispatch, collection.uid, item.uid, varType]);
_var.name = value;
break;
}
case 'value': {
_var.value = e.target.value;
break;
}
case 'enabled': {
_var.enabled = e.target.checked;
break;
}
const handleVarDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveVar({
type: varType,
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [dispatch, varType, collection.uid, item.uid]);
const getRowError = useCallback((row, index, key) => {
if (key !== 'name') return null;
if (!row.name || row.name.trim() === '') return null;
if (!variableNameRegex.test(row.name)) {
return 'Variable contains invalid characters. Must only contain alphanumeric characters, "-", "_", "."';
}
dispatch(
updateVar({
type: varType,
var: _var,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
return null;
}, []);
const handleRemoveVar = (_var) => {
dispatch(
deleteVar({
type: varType,
varUid: _var.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const columns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '35%'
},
{
key: 'value',
name: varType === 'request' ? 'Value' : (
<div className="flex items-center">
<span>Expr</span>
<InfoTip content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}
];
const handleVarDrag = ({ updateReorderedItem }) => {
dispatch(
moveVar({
type: varType,
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
const defaultRow = {
name: '',
value: '',
...(varType === 'response' ? { local: false } : {})
};
return (
<StyledWrapper className="w-full">
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '40%' },
{ name: varType === 'request' ? (
<div className="flex items-center">
<span>Value</span>
</div>
) : (
<div className="flex items-center">
<span>Expr</span>
<InfoTip content="You can write any valid JS expression here" infotipId="response-var" />
</div>
), accessor: 'value', width: '46%' },
{ name: '', accessor: '', width: '14%' }
]}
>
<ReorderTable updateReorderedItem={handleVarDrag}>
{vars && vars.length
? vars.map((_var) => {
return (
<tr key={_var.uid} data-uid={_var.uid}>
<td className="flex relative">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={_var.name}
className="mousetrap"
onChange={(e) => handleVarChange(e, _var, 'name')}
/>
</td>
<td>
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleVarChange(
{
target: {
value: newValue
}
},
_var,
'value'
)}
onRun={handleRun}
collection={collection}
item={item}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={_var.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleVarChange(e, _var, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
<button className="btn-add-var text-link pr-2 py-3 mt-2 select-none" onClick={handleAddVar}>
+ Add
</button>
<EditableTable
columns={columns}
rows={vars || []}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
reorderable={true}
onReorder={handleVarDrag}
/>
</StyledWrapper>
);
};
export default VarsTable;

View File

@@ -2,7 +2,6 @@ import React from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import DeprecationWarning from 'components/DeprecationWarning';
const Vars = ({ item, collection }) => {
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
@@ -11,8 +10,13 @@ const Vars = ({ item, collection }) => {
return (
<StyledWrapper className="w-full flex flex-col">
<div className="mt-2">
<div className="mb-1 title text-xs">Pre Request</div>
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
</div>
<div>
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
</div>
</StyledWrapper>
);
};

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 2.3rem;
height: 2.1rem;
position: relative;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};

View File

@@ -7,7 +7,6 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import Welcome from 'components/Welcome';
import { findItemInCollection } from 'utils/collections';
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
@@ -34,6 +33,7 @@ import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import ResponseExample from 'components/ResponseExample';
import WorkspaceHome from 'components/WorkspaceHome';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -137,7 +137,7 @@ const RequestTabPanel = () => {
}, [dragging]);
if (!activeTabUid) {
return <Welcome />;
return <WorkspaceHome />;
}
if (!focusedTab || !focusedTab.uid || !focusedTab.collectionUid) {
@@ -243,7 +243,7 @@ const RequestTabPanel = () => {
isVerticalLayout ? 'vertical-layout' : ''
}`}
>
<div className="pt-4 pb-3 px-4">
<div className="pt-3 pb-3 px-4">
{
isGrpcRequest
? <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { uuid } from 'utils/common';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
@@ -45,26 +45,28 @@ const CollectionToolBar = ({ collection }) => {
<StyledWrapper>
<div className="flex items-center py-2 px-4">
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
<IconFiles size={18} strokeWidth={1.5} />
<IconBox size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</div>
<div className="flex flex-3 items-center justify-end">
<span className="mr-2">
<JsSandboxMode collection={collection} />
</span>
<span className="mr-3">
<ToolHint text="Runner" toolhintId="RunnnerToolhintId" place="bottom">
<IconRun className="cursor-pointer" size={18} strokeWidth={1.5} onClick={handleRun} />
<IconRun className="cursor-pointer" size={16} strokeWidth={1.5} onClick={handleRun} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<IconEye className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewVariables} />
<IconEye className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewVariables} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
<IconSettings className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<span className="mr-2">
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
<JsSandboxMode collection={collection} />
</ToolHint>
</span>
<span>

View File

@@ -8,8 +8,7 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
import StyledWrapper from '../RequestTab/StyledWrapper';
import CloseTabIcon from '../RequestTab/CloseTabIcon';
import DraftTabIcon from '../RequestTab/DraftTabIcon';
import GradientCloseButton from '../RequestTab/GradientCloseButton';
const ExampleTab = ({ tab, collection }) => {
const dispatch = useDispatch();
@@ -59,7 +58,7 @@ const ExampleTab = ({ tab, collection }) => {
if (!item || !example) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-1"
className="flex items-center justify-between tab-container px-3"
onMouseUp={(e) => {
if (e.button === 1) {
e.preventDefault();
@@ -75,7 +74,7 @@ const ExampleTab = ({ tab, collection }) => {
}
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
<StyledWrapper className="flex items-center justify-between tab-container px-2">
{showConfirmClose && (
<ConfirmRequestClose
item={item}
@@ -103,7 +102,7 @@ const ExampleTab = ({ tab, collection }) => {
/>
)}
<div
className={`flex items-center tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
className={`flex items-center tab-label ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
@@ -116,13 +115,13 @@ const ExampleTab = ({ tab, collection }) => {
}
}}
>
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
<ExampleIcon size={14} color="currentColor" className="mr-1.5 text-gray-500 flex-shrink-0" />
<span className="tab-name" title={example.name}>
{example.name}
</span>
</div>
<div
className="flex px-2 close-icon-container"
<GradientCloseButton
hasChanges={hasChanges}
onClick={(e) => {
if (!hasChanges) {
return handleCloseClick(e);
@@ -132,13 +131,7 @@ const ExampleTab = ({ tab, collection }) => {
e.preventDefault();
setShowConfirmClose(true);
}}
>
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
)}
</div>
/>
</StyledWrapper>
);
};

View File

@@ -0,0 +1,106 @@
import styled from 'styled-components';
const StyledWrapper = styled.div.attrs((props) => ({
style: {
'--gradient-color': props.theme.requestTabs.bg,
'--gradient-color-active': props.theme.bg
}
}))`
display: flex;
align-items: center;
justify-content: flex-end;
position: absolute;
width: 44px;
height: 100%;
right: 0;
top: 0;
padding-right: 4px;
z-index: 3;
background-image: linear-gradient(
90deg,
transparent 0%,
var(--gradient-color) 40%
);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
li.active & {
background-image: linear-gradient(
90deg,
transparent 0%,
var(--gradient-color-active) 40%
);
}
li:hover &,
&.has-changes {
opacity: 1;
pointer-events: auto;
}
.close-icon-container {
display: flex;
justify-content: center;
align-items: center;
width: 22px;
height: 22px;
border-radius: ${(props) => props.theme.border.radius.base};
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
.close-icon {
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
}
}
}
.close-icon {
color: ${(props) => props.theme.requestTabs.icon.color};
width: 12px;
height: 12px;
transition: color 0.12s ease;
}
.has-changes-icon {
width: 8px;
height: 8px;
}
.draft-icon-wrapper {
display: none;
}
.close-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
&.has-changes:not(li:hover &) {
.draft-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.close-icon-wrapper {
display: none;
}
}
li:hover &.has-changes {
.draft-icon-wrapper {
display: none;
}
.close-icon-wrapper {
display: flex;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import CloseTabIcon from '../CloseTabIcon';
import DraftTabIcon from '../DraftTabIcon';
import StyledWrapper from './StyledWrapper';
const GradientCloseButton = ({ onClick, hasChanges = false }) => {
return (
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''}`}>
<div className="close-icon-container" onClick={onClick} data-testid="request-tab-close-icon">
<span className="draft-icon-wrapper">
<DraftTabIcon />
</span>
<span className="close-icon-wrapper">
<CloseTabIcon />
</span>
</div>
</StyledWrapper>
);
};
export default GradientCloseButton;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
@@ -8,49 +7,49 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
switch (type) {
case 'collection-settings': {
return (
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span>
</div>
<>
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Collection</span>
</>
);
}
case 'collection-overview': {
return (
<>
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span>
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Overview</span>
</>
);
}
case 'security-settings': {
return (
<>
<IconShieldLock size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Security</span>
<IconShieldLock size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Security</span>
</>
);
}
case 'folder-settings': {
return (
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
</div>
<>
<IconFolder size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">{tabName || 'Folder'}</span>
</>
);
}
case 'variables': {
return (
<>
<IconVariable size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Variables</span>
<IconVariable size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Variables</span>
</>
);
}
case 'collection-runner': {
return (
<>
<IconRun size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Runner</span>
<IconRun size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Runner</span>
</>
);
}
@@ -59,10 +58,13 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
return (
<>
<div className="flex items-center tab-label pl-2">{getTabInfo(type, tabName)}</div>
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
{hasDraft ? <DraftTabIcon /> : <CloseTabIcon />}
<div
className="flex items-baseline tab-label"
onDoubleClick={handleDoubleClick}
>
{getTabInfo(type, tabName)}
</div>
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
</>
);
};

View File

@@ -1,43 +1,32 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
width: 100%;
height: 100%;
.tab-label {
overflow: hidden;
align-items: center;
position: relative;
flex: 1;
min-width: 0;
}
.tab-method {
font-size: 0.6875rem;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.tab-name {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
font-size: 0.8125rem;
.close-icon-container {
min-height: 20px;
min-width: 24px;
margin-left: 4px;
border-radius: 3px;
.close-icon {
display: none;
color: ${(props) => props.theme.requestTabs.icon.color};
width: 8px;
padding-bottom: 6px;
padding-top: 6px;
}
&:hover,
&:hover .close-icon {
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
}
.has-changes-icon {
height: 24px;
}
.tab-method {
font-size: ${(props) => props.theme.font.size.sm};
}
// so that the name does not cutoff when italicized
padding-right: 2px;
}
`;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef, Fragment, useMemo } from 'react';
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
@@ -17,16 +17,17 @@ import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
import NewRequest from 'components/Sidebar/NewRequest/index';
import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import GradientCloseButton from './GradientCloseButton';
import { flattenItems } from 'utils/collections/index';
import { closeWsConnection } from 'utils/network/index';
import ExampleTab from '../ExampleTab';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
const tabNameRef = useRef(null);
const lastOverflowStateRef = useRef(null);
const [showConfirmClose, setShowConfirmClose] = useState(false);
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false);
@@ -36,6 +37,48 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const item = findItemInCollection(collection, tab.uid);
const method = useMemo(() => {
if (!item) return;
switch (item.type) {
case 'grpc-request':
return 'gRPC';
case 'ws-request':
return 'WS';
case 'graphql-request':
return 'GQL';
default:
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
}
}, [item]);
useEffect(() => {
if (!item || !tabNameRef.current || !setHasOverflow) return;
const checkOverflow = () => {
if (tabNameRef.current && setHasOverflow) {
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
if (lastOverflowStateRef.current !== hasOverflow) {
lastOverflowStateRef.current = hasOverflow;
setHasOverflow(hasOverflow);
}
}
};
const timeoutId = setTimeout(checkOverflow, 0);
const resizeObserver = new ResizeObserver(() => {
checkOverflow();
});
if (tabNameRef.current) {
resizeObserver.observe(tabNameRef.current);
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [item, item?.name, method, setHasOverflow]);
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
@@ -105,11 +148,12 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? 'italic' : ''}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
onMouseUp={handleMouseUp}
>
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
<ConfirmCollectionClose
@@ -192,21 +236,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
}
const getMethodText = useCallback((item) => {
if (!item) return;
switch (item.type) {
case 'grpc-request':
return 'gRPC';
case 'ws-request':
return 'WS';
case 'graphql-request':
return 'GQL';
default:
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
}
}, [item]);
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
if (!item) {
@@ -228,10 +257,36 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
const isWS = item.type === 'ws-request';
const method = getMethodText(item);
useEffect(() => {
const checkOverflow = () => {
if (tabNameRef.current && setHasOverflow) {
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
if (lastOverflowStateRef.current !== hasOverflow) {
lastOverflowStateRef.current = hasOverflow;
setHasOverflow(hasOverflow);
}
}
};
const timeoutId = setTimeout(checkOverflow, 0);
const resizeObserver = new ResizeObserver(() => {
checkOverflow();
});
if (tabNameRef.current) {
resizeObserver.observe(tabNameRef.current);
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [item.name, method, setHasOverflow]);
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
<StyledWrapper className="flex items-center justify-between tab-container px-2">
{showConfirmClose && (
<ConfirmRequestClose
item={item}
@@ -268,7 +323,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
<div
className={`flex items-baseline tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
@@ -284,7 +339,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
{method}
</span>
<span className="ml-1 tab-name" title={item.name}>
<span ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
{item.name}
</span>
<RequestTabMenu
@@ -297,25 +352,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
dispatch={dispatch}
/>
</div>
<div
className="flex px-2 close-icon-container"
<GradientCloseButton
hasChanges={hasChanges}
onClick={(e) => {
if (!hasChanges) {
isWS && closeWsConnection(item.uid);
return handleCloseClick(e);
};
}
e.stopPropagation();
e.preventDefault();
setShowConfirmClose(true);
}}
>
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
)}
</div>
/>
</StyledWrapper>
);
};
@@ -349,7 +398,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
}
dispatch(closeTabs({ tabUids: [tabUid] }));
} catch (err) {}
} catch (err) { }
}
function handleRevertChanges(event) {
@@ -368,7 +417,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
collectionUid: collection.uid
}));
}
} catch (err) {}
} catch (err) { }
}
function handleCloseOtherTabs(event) {

View File

@@ -1,13 +1,44 @@
import styled from 'styled-components';
const Wrapper = styled.div`
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${(props) => props.theme.requestTabs.bottomBorder};
z-index: 0;
}
.tabs-scroll-container {
overflow-x: auto;
overflow-y: clip;
padding-bottom: 10px;
margin-bottom: -10px;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
ul {
margin-bottom: 0;
overflow: visible;
}
}
ul {
padding: 0;
padding: 0 3px;
margin: 0;
display: flex;
overflow: scroll;
align-items: flex-end;
position: relative;
z-index: 1;
&::-webkit-scrollbar {
display: none;
@@ -17,57 +48,127 @@ const Wrapper = styled.div`
li {
display: inline-flex;
max-width: 150px;
border: 1px solid transparent;
max-width: 180px;
min-width: 80px;
list-style: none;
padding-top: 8px;
padding-bottom: 8px;
padding-left: 0;
padding-right: 0;
cursor: pointer;
font-size: ${(props) => props.theme.font.size.base};
height: 38px;
margin-right: 6px;
font-size: 0.8125rem;
position: relative;
margin-right: 3px;
color: ${(props) => props.theme.requestTabs.color};
background: ${(props) => props.theme.requestTabs.bg};
border-radius: 0;
background: transparent;
border: 1px solid transparent;
padding: 6px 0;
flex-shrink: 0;
transition: background-color 0.15s ease;
margin-bottom: 3px;
.tab-container {
width: 100%;
position: relative;
overflow: hidden;
}
&:not(.active) {
background: ${(props) => props.theme.requestTabs.bg};
border-color: transparent;
border-radius: ${(props) => props.theme.border.radius.base};
}
&:nth-last-child(1) {
margin-right: 10px;
}
&.has-overflow:not(:hover) .tab-name {
mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
transparent 100%
);
}
&.has-overflow:hover .tab-name {
mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
transparent 100%
);
}
&.active {
background: ${(props) => props.theme.requestTabs.active.bg};
}
background: ${(props) => props.theme.bg || '#ffffff'};
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom-color: ${(props) => props.theme.bg || '#ffffff'};
border-radius: 8px 8px 0 0;
z-index: 2;
margin-bottom: -2px;
padding-bottom: 12px;
&.active {
.close-icon-container .close-icon {
display: block;
&::before {
content: '';
position: absolute;
bottom: 1px;
left: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-right-radius: 6px;
box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}
}
&:hover {
.close-icon-container .close-icon {
display: block;
&::after {
content: '';
position: absolute;
bottom: 1px;
right: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-left-radius: 6px;
box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}
}
&.short-tab {
vertical-align: bottom;
width: 34px;
min-width: 34px;
max-width: 34px;
padding: 3px 0px;
width: 32px;
min-width: 32px;
max-width: 32px;
padding: 5px 0;
display: inline-flex;
justify-content: center;
align-items: center;
color: ${(props) => props.theme.requestTabs.shortTab.color};
background-color: ${(props) => props.theme.requestTabs.shortTab.bg};
position: relative;
top: -1px;
background-color: transparent;
border: 1px solid transparent;
border-radius: ${(props) => props.theme.border.radius.base};
flex-shrink: 0;
> div {
padding: 3px 4px;
padding: 3px;
display: flex;
align-items: center;
justify-content: center;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: background-color 0.12s ease, color 0.12s ease;
}
> div.home-icon-container {
@@ -81,19 +182,23 @@ const Wrapper = styled.div`
}
svg {
height: 22px;
height: 20px;
width: 20px;
}
&:hover {
> div {
background-color: ${(props) => props.theme.requestTabs.shortTab.hoverBg};
color: ${(props) => props.theme.requestTabs.shortTab.hoverColor};
border-radius: 3px;
}
}
}
}
}
&.has-chevrons ul {
padding-left: 0;
}
`;
export default Wrapper;

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import find from 'lodash/find';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -10,11 +10,15 @@ import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
import CreateUntitledRequest from 'components/CreateUntitledRequest';
const RequestTabs = () => {
const dispatch = useDispatch();
const tabsRef = useRef();
const scrollContainerRef = useRef();
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [tabOverflowStates, setTabOverflowStates] = useState({});
const [showChevrons, setShowChevrons] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const collections = useSelector((state) => state.collections.collections);
@@ -22,10 +26,48 @@ const RequestTabs = () => {
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
const createSetHasOverflow = useCallback((tabUid) => {
return (hasOverflow) => {
setTabOverflowStates((prev) => {
if (prev[tabUid] === hasOverflow) {
return prev;
}
return {
...prev,
[tabUid]: hasOverflow
};
});
};
}, []);
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
const activeCollection = find(collections, (c) => c.uid === activeTab?.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
useEffect(() => {
if (!activeTabUid || !activeTab) return;
const checkOverflow = () => {
if (tabsRef.current && scrollContainerRef.current) {
const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth;
setShowChevrons(hasOverflow);
}
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
if (scrollContainerRef.current) {
resizeObserver.observe(scrollContainerRef.current);
}
return () => resizeObserver.disconnect();
}, [activeTabUid, activeTab, collectionRequestTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]);
const getTabClassname = (tab, index) => {
return classnames('request-tab select-none', {
'active': tab.uid === activeTabUid,
'last-tab': tabs && tabs.length && index === tabs.length - 1
'last-tab': tabs && tabs.length && index === tabs.length - 1,
'has-overflow': tabOverflowStates[tab.uid]
});
};
@@ -37,37 +79,26 @@ const RequestTabs = () => {
);
};
const createNewTab = () => setNewRequestModalOpen(true);
if (!activeTabUid) {
return null;
}
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) {
return <StyledWrapper>Something went wrong!</StyledWrapper>;
}
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
const showChevrons = maxTablistWidth < tabsWidth;
const leftSlide = () => {
tabsRef.current.scrollBy({
scrollContainerRef.current?.scrollBy({
left: -120,
behavior: 'smooth'
});
};
// todo: bring new tab to focus if its not in focus
// tabsRef.current.scrollLeft
const rightSlide = () => {
tabsRef.current.scrollBy({
scrollContainerRef.current?.scrollBy({
left: 120,
behavior: 'smooth'
});
@@ -87,7 +118,7 @@ const RequestTabs = () => {
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
<CollectionToolBar collection={activeCollection} />
<div className="flex items-center pl-4">
<div className="flex items-center pl-2">
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={leftSlide}>
@@ -103,36 +134,40 @@ const RequestTabs = () => {
</div>
</li> */}
</ul>
<ul role="tablist" style={{ maxWidth: maxTablistWidth }} ref={tabsRef}>
{collectionRequestTabs && collectionRequestTabs.length
? collectionRequestTabs.map((tab, index) => {
return (
<DraggableTab
key={tab.uid}
id={tab.uid}
index={index}
onMoveTab={(source, target) => {
dispatch(reorderTabs({
sourceUid: source,
targetUid: target
}));
}}
className={getTabClassname(tab, index)}
onClick={() => handleClick(tab)}
>
<RequestTab
collectionRequestTabs={collectionRequestTabs}
tabIndex={index}
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
<ul role="tablist" ref={tabsRef}>
{collectionRequestTabs && collectionRequestTabs.length
? collectionRequestTabs.map((tab, index) => {
return (
<DraggableTab
key={tab.uid}
tab={tab}
collection={activeCollection}
folderUid={tab.folderUid}
/>
</DraggableTab>
);
})
: null}
</ul>
id={tab.uid}
index={index}
onMoveTab={(source, target) => {
dispatch(reorderTabs({
sourceUid: source,
targetUid: target
}));
}}
className={getTabClassname(tab, index)}
onClick={() => handleClick(tab)}
>
<RequestTab
collectionRequestTabs={collectionRequestTabs}
tabIndex={index}
key={tab.uid}
tab={tab}
collection={activeCollection}
folderUid={tab.folderUid}
hasOverflow={tabOverflowStates[tab.uid]}
setHasOverflow={createSetHasOverflow(tab.uid)}
/>
</DraggableTab>
);
})
: null}
</ul>
</div>
<ul role="tablist">
{showChevrons ? (
@@ -142,19 +177,16 @@ const RequestTabs = () => {
</div>
</li>
) : null}
<li className="select-none short-tab" id="create-new-tab" onClick={createNewTab}>
<div className="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
</div>
</li>
<div className="flex items-center cursor-pointer short-tab">
{activeCollection && (
<CreateUntitledRequest
collectionUid={activeCollection.uid}
itemUid={null}
placement="bottom-start"
/>
)}
</div>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab choose-request">
<div className="flex items-center">

View File

@@ -8,6 +8,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s
import mime from 'mime-types';
import path from 'utils/common/path';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
import Table from 'components/Table-v2';
@@ -206,7 +207,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
</td>
<td>
<div className="flex items-center justify-center pl-4">
<MultiLineEditor
<SingleLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder="Auto"

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,6 +20,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -15,7 +15,7 @@ const ClearTimeline = ({ collection, item }) => {
);
return (
<StyledWrapper className="ml-2 flex items-center">
<StyledWrapper className="flex items-center">
<button onClick={clearResponse} className="text-link hover:underline" title="Clear Timeline">
Clear Timeline
</button>

View File

@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
@@ -51,6 +51,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -11,7 +11,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
@@ -25,6 +25,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -10,7 +10,6 @@ import GrpcStatusCode from './GrpcStatusCode';
import ResponseTime from '../ResponseTime/index';
import Timeline from '../Timeline';
import ClearTimeline from '../ClearTimeline';
import ResponseSave from '../ResponseSave';
import ResponseClear from '../ResponseClear';
import StyledWrapper from './StyledWrapper';
import ResponseTrailers from './ResponseTrailers';

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.console.border};
.query-response-content {
border-top: 1px solid ${(props) => props.theme.console.border};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useState } from 'react';
import QueryResult from '../QueryResult';
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from '../QueryResult/index';
import QueryResultTypeSelector from '../QueryResult/QueryResultTypeSelector/index';
import StyledWrapper from './StyledWrapper';
import classnames from 'classnames';
const QueryResponse = ({
item,
collection,
data,
dataBuffer,
disableRunEventListener,
headers,
error
}) => {
const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers);
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
useEffect(() => {
if (initialFormat !== null && initialTab !== null) {
setSelectedFormat(initialFormat);
setSelectedTab(initialTab);
}
}, [initialFormat, initialTab]);
return (
<StyledWrapper>
<div className="flex items-center justify-end p-2">
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={(newFormat) => {
setSelectedFormat(newFormat);
}}
onPreviewTabSelect={() => {
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
}}
selectedTab={selectedTab}
/>
</div>
<div className={classnames('flex-1 query-response-content', selectedTab === 'editor' ? 'px-2 py-1' : '')}>
<QueryResult
item={item}
collection={collection}
data={data}
dataBuffer={dataBuffer}
disableRunEventListener={disableRunEventListener}
headers={headers}
error={error}
selectedFormat={selectedFormat}
selectedTab={selectedTab}
/>
</div>
</StyledWrapper>
);
};
export default QueryResponse;

View File

@@ -0,0 +1,78 @@
import React, { useRef, useState, useEffect } from 'react';
import { isValidHtml } from 'utils/common/index';
import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index';
const HtmlPreview = React.memo(({ data, baseUrl }) => {
const webviewContainerRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!webviewContainerRef.current) return;
const checkDragging = () => {
const hasDraggingParent = webviewContainerRef.current?.closest('.dragging');
setIsDragging(!!hasDraggingParent);
};
// Watch from a common ancestor where .dragging gets added
const watchTarget = webviewContainerRef.current.closest('.main-section')
|| document.body;
const mutationObserver = new MutationObserver(checkDragging);
mutationObserver.observe(watchTarget, {
attributes: true,
attributeFilter: ['class'],
subtree: true
});
// Check initial state
checkDragging();
return () => mutationObserver.disconnect();
}, []);
if (isValidHtml(data) || isValidHtmlSnippet(data)) {
const htmlContent = data.includes('<head>')
? data.replace('<head>', `<head><base href="${escapeHtml(baseUrl)}">`)
: `<head><base href="${escapeHtml(baseUrl)}"></head>${data}`;
const dragStyles = isDragging ? { pointerEvents: 'none', userSelect: 'none' } : {};
return (
<div
ref={webviewContainerRef}
className="h-full bg-white webview-container"
style={dragStyles}
>
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(htmlContent)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
style={dragStyles}
/>
</div>
);
}
// For all other data types, render safely as formatted text
let displayContent = '';
if (data === null || data === undefined) {
displayContent = String(data);
} else if (typeof data === 'object') {
displayContent = JSON.stringify(data, null);
} else if (typeof data === 'string') {
displayContent = data;
} else {
displayContent = String(data);
}
return (
<pre
className="bg-white font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden p-4 text-[#24292f] w-full max-w-full h-full box-border relative"
>
{displayContent}
</pre>
);
});
export default HtmlPreview;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import ReactJson from 'react-json-view';
import ErrorAlert from 'ui/ErrorAlert/index';
const JsonPreview = ({ data, displayedTheme }) => {
// Helper function to validate and parse JSON data
const validateJsonData = (data) => {
// If data is already an object or array, use it directly
if (typeof data === 'object' && data !== null) {
return { data, error: null };
}
// If data is a string, try to parse it
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return { data: parsed, error: null };
} catch (e) {
return { data: null, error: `Invalid JSON format: ${e.message}` };
}
}
// For other types, return error
return { data: null, error: 'Invalid input. Expected a JSON object, array, or valid JSON string.' };
};
// Validate and parse JSON data
const jsonData = validateJsonData(data);
// Show error if parsing failed
if (jsonData.error) {
return <ErrorAlert title="Cannot preview as JSON" message={jsonData.error} />;
}
// Validate that data can be rendered as JSON tree
if (jsonData.data === null || jsonData.data === undefined) {
return <ErrorAlert title="Cannot preview as JSON" message="Data is null or undefined. Expected a valid JSON object or array." />;
}
if (typeof jsonData.data !== 'object') {
return <ErrorAlert title="Cannot preview as JSON" message="Data cannot be rendered as a JSON tree. Expected a JSON object or array." />;
}
return (
<ReactJson
src={jsonData.data}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
collapsed={1}
displayDataTypes={false}
displayObjectSize={true}
enableClipboard={true}
name={false}
style={{
backgroundColor: 'transparent',
fontSize: '12px',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
padding: '16px'
}}
/>
);
};
export default JsonPreview;

View File

@@ -0,0 +1,25 @@
import React, { memo, useMemo } from 'react';
const TextPreview = memo(({ data }) => {
const displayData = useMemo(() => {
if (data === null || data === undefined) {
return String(data);
}
if (typeof data === 'object') {
try {
return JSON.stringify(data);
} catch {
return String(data);
}
}
return String(data);
}, [data]);
return (
<div className="p-4 font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden w-full max-w-full h-full">
{displayData}
</div>
);
});
export default TextPreview;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { useEffect, useState } from 'react';
import ReactPlayer from 'react-player';
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
const videoType = contentType.split(';')[0];
const byteArray = Buffer.from(dataBuffer, 'base64');
const blob = new Blob([byteArray], { type: videoType });
const url = URL.createObjectURL(blob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [contentType, dataBuffer]);
if (!videoUrl) return <div>Loading video...</div>;
return (
<ReactPlayer
url={videoUrl}
controls
muted={true}
width="100%"
height="100%"
onError={(e) => console.error('Error loading video:', e)}
/>
);
});
export default VideoPreview;

View File

@@ -0,0 +1,77 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 20px;
padding: 16px;
overflow: auto;
color: ${(props) => props.theme.text};
.xml-container {
color: ${(props) => props.theme.text};
}
.xml-node-name {
color: ${(props) => props.theme.codemirror.tokens.property};
font-weight: 500;
}
.xml-separator {
color: ${(props) => props.theme.codemirror.tokens.operator};
margin: 0 8px;
}
.xml-value {
color: ${(props) => props.theme.codemirror.tokens.string};
white-space: pre-wrap;
word-break: break-all;
}
.xml-empty-value {
color: ${(props) => props.theme.codemirror.tokens.comment};
}
.xml-count {
color: ${(props) => props.theme.codemirror.tokens.comment};
margin-left: 8px;
}
.xml-toggle-button {
margin-right: 8px;
cursor: pointer;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.codemirror.tokens.atom};
flex-shrink: 0;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: ${(props) => props.theme.console.buttonHoverBg};
}
}
.xml-array-toggle-button {
margin-right: 8px;
cursor: pointer;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.codemirror.tokens.atom};
flex-shrink: 0;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: ${(props) => props.theme.console.buttonHoverBg};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,396 @@
import ErrorAlert from 'ui/ErrorAlert/index';
import React, { useState, useMemo } from 'react';
import StyledWrapper from './StyledWrapper';
// The expected "data" prop must be an XML string.
export default function XmlPreview({ data, defaultExpanded = true }) {
// Parse XML string
const parsedData = useMemo(() => {
if (typeof data !== 'string') {
return { error: 'Invalid input. Expected an XML string.' };
}
const parsed = parseXMLString(data);
if (parsed === null) {
return { error: 'Failed to parse XML string. Invalid XML format.' };
}
return parsed;
}, [data]);
// Check for parsing error
if (parsedData && typeof parsedData === 'object' && parsedData.error) {
return (
<ErrorAlert title="Cannot preview as XML" message={parsedData.error} />
);
}
// Validate that data can be rendered as a tree
const isValidTreeData = (data) => {
if (data === null || data === undefined) return false;
if (typeof data === 'object' && !Array.isArray(data)) return true;
if (Array.isArray(data)) return true;
return false;
};
if (!isValidTreeData(parsedData)) {
return (
<ErrorAlert title="Cannot preview as XML" message="Data cannot be rendered as a tree. Expected a valid XML string." />
);
}
// If root is an object with a single key, unwrap it to show the actual root element
let rootNode = parsedData;
let rootNodeName = '';
if (typeof parsedData === 'object' && !Array.isArray(parsedData) && parsedData !== null) {
const keys = Object.keys(parsedData).filter((k) => k !== '$' && k !== '@_' && k !== '#text');
if (keys.length === 1) {
rootNodeName = keys[0];
rootNode = parsedData[keys[0]];
} else if (keys.length === 0) {
// Empty object with no children
return (
<ErrorAlert title="Cannot preview as XML" message="Cannot render XML tree. Root object is empty." />
);
}
}
return (
<StyledWrapper>
<div className="xml-container">
<XmlNode
node={rootNode}
nodeName={rootNodeName}
isRoot={true}
isLast={true}
defaultExpanded={defaultExpanded}
/>
</div>
</StyledWrapper>
);
}
// Component for rendering array entries with expand/collapse functionality
const XmlArrayNode = ({ arrayKey, items, depth, defaultExpanded = true }) => {
const [expanded, setExpanded] = useState(defaultExpanded);
const toggle = (e) => {
e.stopPropagation();
setExpanded((v) => !v);
};
return (
<div style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
<div className="flex items-center mb-1">
<button
onClick={toggle}
className="xml-array-toggle-button"
tabIndex={-1}
aria-expanded={expanded}
>
{expanded ? '▼' : '▶'}
</button>
<span className="xml-node-name">{arrayKey}</span>
<span className="xml-count">[{items.length}]</span>
</div>
{expanded && (
<div className="array-content">
{items.map((item, itemIdx) => (
<XmlNode
key={`${arrayKey}-${itemIdx}`}
node={item}
nodeName={`${itemIdx}`}
isLast={itemIdx === items.length - 1}
defaultExpanded={false}
depth={depth + 2}
/>
))}
</div>
)}
</div>
);
};
const XmlNode = ({
node,
nodeName = '',
isRoot = false,
isLast = true,
defaultExpanded = true,
depth = 0
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
let displayNodeName = nodeName;
if (Array.isArray(node)) {
// For repeated XML elements with same name (e.g. <item>...</item><item>...</item>)
return (
<>
{node.map((item, idx) => (
<XmlNode
key={idx}
node={item}
nodeName={displayNodeName}
isRoot={false}
isLast={idx === node.length - 1}
defaultExpanded={false}
depth={depth}
/>
))}
</>
);
}
const childEntries = getChildrenEntries(node);
const childCount = getChildCount(node);
const isLeaf = isTextNode(node) || (typeof node === 'object' && childCount === 0);
const toggle = (e) => {
e.stopPropagation();
setExpanded((v) => !v);
};
// For leaf nodes with text content or attributes with empty values
if (isLeaf && isTextNode(node)) {
const value = String(node);
return (
<div className="flex items-start mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
{displayNodeName && (
<>
<span className="xml-node-name">{displayNodeName}</span>
<span className="xml-separator">:</span>
</>
)}
<span className="xml-value">{value}</span>
</div>
);
}
// For empty leaf nodes (attributes without values, etc)
if (isLeaf && !isTextNode(node)) {
// Check if this is an attribute-only node with _text
if (typeof node === 'object' && node !== null && '_text' in node) {
// This node has both attributes and text, handle in expandable section
// Fall through to expandable node rendering
} else {
return (
<div className="flex items-center mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
{displayNodeName && (
<>
<span className="xml-node-name">{displayNodeName}</span>
<span className="xml-separator">:</span>
<span className="xml-empty-value">{'{}'}</span>
</>
)}
</div>
);
}
}
// For expandable nodes - show as tree structure
// If no node name at root level, render children directly
if (!displayNodeName && depth === 0) {
if (childEntries.length > 0) {
return (
<div>
{childEntries.map(([key, value], idx) => (
<XmlNode
key={key + idx}
node={value}
nodeName={key}
isLast={idx === childEntries.length - 1}
defaultExpanded={defaultExpanded}
depth={0}
/>
))}
</div>
);
}
return null;
}
// If no display name at non-root level, use a fallback
if (!displayNodeName) {
displayNodeName = '(unnamed)';
}
// Determine if this node's value is an array
const hasArrayValue = Array.isArray(node);
const arrayLength = hasArrayValue ? node.length : 0;
return (
<div style={{ paddingLeft: `${depth * 20}px` }}>
<div className="flex items-center mb-1">
<button
onClick={toggle}
className="xml-toggle-button"
tabIndex={-1}
aria-expanded={expanded}
>
{expanded ? '▼' : '▶'}
</button>
<span className="xml-node-name">
{displayNodeName}
</span>
{childCount > 0 && (
<span className="xml-count">
{`{${childCount}}`}
</span>
)}
</div>
{expanded && childEntries.length > 0 && (
<div>
{childEntries.map(([key, value], idx) => {
// Check if this is an attribute (starts with _)
const isAttribute = key.startsWith('_');
// Handle attributes
if (isAttribute) {
const displayValue = value === '' ? 'value' : value;
return (
<div key={key + idx} className="flex items-start mb-1" style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
<span className="xml-node-name">{key}</span>
<span className="xml-separator">:</span>
<span className={value === '' ? 'xml-empty-value' : 'xml-value'}>{displayValue}</span>
</div>
);
}
// Check if this child is an array
const isArrayChild = Array.isArray(value);
if (isArrayChild) {
return (
<XmlArrayNode
key={`${key}-${idx}`}
arrayKey={key}
items={value}
depth={depth}
defaultExpanded={true}
/>
);
}
return (
<XmlNode
key={key + idx}
node={value}
nodeName={key}
isLast={idx === childEntries.length - 1}
defaultExpanded={false}
depth={depth + 1}
/>
);
})}
</div>
)}
</div>
);
};
// Helper function to parse XML string to object
function parseXMLString(xmlString) {
if (typeof xmlString !== 'string') return null;
try {
const parser = new DOMParser();
// Parse as XML only
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
// Check for parsing errors
const parserError = xmlDoc.querySelector('parsererror');
if (parserError) {
return null;
}
// Convert XML DOM to object
function xmlToObject(node) {
if (node.nodeType !== 1) return null; // Not an element node
const result = {};
// Get attributes - store them directly with underscore prefix
if (node.attributes && node.attributes.length > 0) {
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
result[`_${attr.name}`] = attr.value || '';
}
}
// Get child nodes
const childNodes = Array.from(node.childNodes);
const elementChildren = childNodes.filter((child) => child.nodeType === 1);
const textChildren = childNodes.filter((child) => child.nodeType === 3 && child.textContent.trim());
// If only text children and no element children, return text content
if (elementChildren.length === 0 && textChildren.length > 0) {
const textContent = textChildren.map((t) => t.textContent.trim()).join(' ').trim();
// If has attributes, store text as a special property
if (Object.keys(result).length > 0) {
result['_text'] = textContent;
return result;
}
return textContent || null;
}
// Process element children
if (elementChildren.length > 0) {
const childMap = {};
elementChildren.forEach((child) => {
const childName = child.nodeName; // Preserve original casing
const childValue = xmlToObject(child);
if (childValue !== null || elementChildren.filter((c) => c.nodeName.toLowerCase() === childName).length > 1) {
if (childMap[childName]) {
// Multiple children with same name - convert to array
if (!Array.isArray(childMap[childName])) {
childMap[childName] = [childMap[childName]];
}
childMap[childName].push(childValue);
} else {
childMap[childName] = childValue;
}
}
});
// Merge children into result
Object.assign(result, childMap);
}
return Object.keys(result).length > 0 ? result : null;
}
const rootElement = xmlDoc.documentElement;
if (!rootElement) return null;
const parsed = xmlToObject(rootElement);
return parsed ? { [rootElement.nodeName]: parsed } : null;
} catch (error) {
return null;
}
}
function isTextNode(node) {
return typeof node === 'string' || typeof node === 'number' || node === null;
}
function getChildrenEntries(node) {
// Given an XML-like JS object, return an array of [key, value] for all properties
// This includes attributes (with _ prefix) and child elements
if (typeof node !== 'object' || node === null) return [];
return Object.entries(node);
}
function getChildCount(node) {
if (Array.isArray(node)) {
return node.length;
}
const children = getChildrenEntries(node);
return children.length;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useMemo } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import find from 'lodash/find';
@@ -11,44 +11,22 @@ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs';
import ReactPlayer from 'react-player';
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
const videoType = contentType.split(';')[0];
const byteArray = Buffer.from(dataBuffer, 'base64');
const blob = new Blob([byteArray], { type: videoType });
const url = URL.createObjectURL(blob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [contentType, dataBuffer]);
if (!videoUrl) return <div>Loading video...</div>;
return (
<ReactPlayer
url={videoUrl}
controls
muted={true}
width="100%"
height="100%"
onError={(e) => console.error('Error loading video:', e)}
/>
);
});
import XmlPreview from './XmlPreview/index';
import TextPreview from './TextPreview';
import HtmlPreview from './HtmlPreview';
import VideoPreview from './VideoPreview';
import JsonPreview from './JsonPreview';
const QueryResultPreview = ({
previewTab,
allowedPreviewModes,
selectedTab,
data,
dataBuffer,
formattedData,
item,
contentType,
collection,
mode,
codeMirrorMode,
previewMode,
disableRunEventListener,
displayedTheme
}) => {
@@ -63,10 +41,6 @@ const QueryResultPreview = ({
function onDocumentLoadSuccess({ numPages }) {
setNumPages(numPages);
}
// Fail safe, so we don't render anything with an invalid tab
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
return null;
}
const onRun = () => {
if (disableRunEventListener) {
@@ -87,19 +61,31 @@ const QueryResultPreview = ({
);
};
switch (previewTab?.mode) {
if (selectedTab === 'editor') {
return (
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={codeMirrorMode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);
}
switch (previewMode) {
case 'preview-web': {
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
return (
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
/>
);
const baseUrl = item.requestSent?.url || '';
return <HtmlPreview data={data} baseUrl={baseUrl} />;
}
case 'preview-image': {
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />;
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} />;
}
case 'preview-pdf': {
return (
@@ -120,24 +106,29 @@ const QueryResultPreview = ({
case 'preview-video': {
return <VideoPreview contentType={contentType} dataBuffer={dataBuffer} />;
}
default:
case 'raw': {
return (
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={mode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);
case 'preview-json': {
return <JsonPreview data={data} displayedTheme={displayedTheme} />;
}
case 'preview-text': {
return <TextPreview data={data} />;
}
case 'preview-xml': {
return <XmlPreview data={data} />;
}
default:
return (
<div className="p-4 flex flex-col items-center justify-center h-full text-center">
<div className="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Preview Available
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Sorry, no preview is available for this content type.
</div>
</div>
);
}
};

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
.preview-response-tab-label {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { IconEye } from '@tabler/icons';
import ButtonDropdown from 'ui/ButtonDropdown';
import ToggleSwitch from 'components/ToggleSwitch';
import StyledWrapper from './StyledWrapper';
const QueryResultTypeSelector = ({
formatOptions,
formatValue,
onFormatChange,
onPreviewTabSelect,
selectedTab
}) => {
const header = (
<div className="flex items-center justify-between gap-3 py-[0.35rem] px-[0.6rem]">
<span className="text-[0.8125rem] preview-response-tab-label">Preview</span>
<ToggleSwitch
isOn={selectedTab === 'preview'}
handleToggle={(e) => {
e.preventDefault();
// e.stopPropagation();
onPreviewTabSelect();
}}
size="2xs"
data-testid="preview-response-tab"
title={selectedTab === 'preview' ? 'Turn off Preview Mode' : 'Turn on Preview Mode'}
/>
</div>
);
return (
<StyledWrapper>
<ButtonDropdown
label={formatValue}
options={formatOptions}
value={formatValue}
onChange={onFormatChange}
header={header}
className="h-[20px] text-[11px]"
data-testid="format-response-tab"
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
/>
</StyledWrapper>
);
};
export default QueryResultTypeSelector;

View File

@@ -1,9 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: grid;
grid-template-columns: 100%;
grid-template-rows: 1.25rem 1fr;
display: flex;
flex-direction: column;
/* This is a hack to force Codemirror to use all available space */
> div {

View File

@@ -1,15 +1,34 @@
import { debounce } from 'lodash';
import { useTheme } from 'providers/Theme/index';
import React, { useMemo, useState } from 'react';
import { formatResponse, getContentType } from 'utils/common';
import { getEncoding } from 'utils/common/index';
import { getDefaultResponseFormat } from 'utils/response';
import LargeResponseWarning from '../LargeResponseWarning';
import QueryResultFilter from './QueryResultFilter';
import React from 'react';
import classnames from 'classnames';
import { getContentType, formatResponse } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import QueryResultPreview from './QueryResultPreview';
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';
import { detectContentTypeFromBuffer } from 'utils/response/index';
const PREVIEW_FORMAT_OPTIONS = [
{
// name: 'Structured',
options: [
{ label: 'JSON', value: 'json', codeMirrorMode: 'application/ld+json' },
{ label: 'HTML', value: 'html', codeMirrorMode: 'xml' },
{ label: 'XML', value: 'xml', codeMirrorMode: 'xml' },
{ label: 'JavaScript', value: 'javascript', codeMirrorMode: 'javascript' }
]
},
{
// name: 'Raw',
options: [
{ label: 'Raw', value: 'raw', codeMirrorMode: 'text/plain' },
{ label: 'Hex', value: 'hex', codeMirrorMode: 'text/plain' },
{ label: 'Base64', value: 'base64', codeMirrorMode: 'text/plain' }
]
}
];
const formatErrorMessage = (error) => {
if (!error) return 'Something went wrong';
@@ -24,9 +43,87 @@ const formatErrorMessage = (error) => {
return error;
};
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
// Custom hook to determine the initial format and tab based on the data buffer and headers
export const useInitialResponseFormat = (dataBuffer, headers) => {
return useMemo(() => {
let buffer = null;
try {
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const contentType = getContentType(headers);
// Wait until both content types are available
if (detectedContentType === null || contentType === undefined) {
return { initialFormat: null, initialTab: null };
}
const initial = getDefaultResponseFormat(contentType);
return { initialFormat: initial.format, initialTab: initial.tab };
}, [dataBuffer, headers]);
};
// Custom hook to determine preview format options based on content type
export const useResponsePreviewFormatOptions = (dataBuffer, headers) => {
return useMemo(() => {
let buffer = null;
try {
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const contentType = getContentType(headers);
const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip'];
const isByteFormatType = (contentType) => {
return byteFormatTypes.some((type) => contentType.includes(type));
};
const getContentTypeToCheck = () => {
if (detectedContentType) {
return detectedContentType;
}
return contentType;
};
const contentTypeToCheck = getContentTypeToCheck();
if (contentTypeToCheck && isByteFormatType(contentTypeToCheck)) {
return PREVIEW_FORMAT_OPTIONS.slice(1, 2); // Remove structured format options
}
return PREVIEW_FORMAT_OPTIONS;
}, [dataBuffer, headers]);
};
const QueryResult = ({
item,
collection,
data,
dataBuffer,
disableRunEventListener,
headers,
error,
selectedFormat, // one of the options in PREVIEW_FORMAT_OPTIONS
selectedTab // 'editor' or 'preview'
}) => {
let buffer = null;
try {
buffer = Buffer.from(dataBuffer, 'base64'); // dataBuffer is already a base64 string, convert it to actual Buffer
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
const [showLargeResponse, setShowLargeResponse] = useState(false);
const responseEncoding = getEncoding(headers);
@@ -56,65 +153,44 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
if (isLargeResponse && !showLargeResponse) {
return '';
}
return formatResponse(data, dataBuffer, mode, filter);
return formatResponse(data, dataBuffer, selectedFormat, filter);
},
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
[data, dataBuffer, responseEncoding, selectedFormat, filter, isLargeResponse, showLargeResponse]
);
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);
const allowedPreviewModes = useMemo(() => {
// Always show raw
const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
const previewMode = useMemo(() => {
// Derive preview mode based on selected format
if (selectedFormat === 'html') return 'preview-web';
if (selectedFormat === 'json') return 'preview-json';
if (selectedFormat === 'xml') return 'preview-xml';
if (selectedFormat === 'raw') return 'preview-text';
if (selectedFormat === 'javascript') return 'preview-web';
if (!mode || !contentType) return allowedPreviewModes;
if (mode?.includes('html') && typeof data === 'string') {
allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
} else if (mode.includes('image')) {
allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
} else if (contentType.includes('pdf')) {
allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() });
} else if (contentType.includes('audio')) {
allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() });
} else if (contentType.includes('video')) {
allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() });
// For base64/hex, check content type to determine binary preview type
if (selectedFormat === 'base64' || selectedFormat === 'hex') {
if (detectedContentType) {
if (detectedContentType.includes('image')) return 'preview-image';
if (detectedContentType.includes('pdf')) return 'preview-pdf';
if (detectedContentType.includes('audio')) return 'preview-audio';
if (detectedContentType.includes('video')) return 'preview-video';
}
// for all other content types, return preview-text
return 'preview-text';
}
return 'preview-text';
}, [selectedFormat, detectedContentType]);
return allowedPreviewModes;
}, [mode, data, formattedData]);
const codeMirrorMode = useMemo(() => {
return PREVIEW_FORMAT_OPTIONS
.flatMap((option) => option.options)
.find((option) => option.value === selectedFormat)?.codeMirrorMode || 'text/plain';
}, [selectedFormat]);
const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
// Ensure the active Tab is always allowed
useEffect(() => {
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
setPreviewTab(allowedPreviewModes[0]);
}
}, [previewTab, allowedPreviewModes]);
const tabs = useMemo(() => {
if (allowedPreviewModes.length === 1) {
return null;
}
return allowedPreviewModes.map((previewMode) => (
<div
className={classnames(
'select-none capitalize',
previewMode?.uid === previewTab?.uid ? 'active' : 'cursor-pointer'
)}
role="tab"
onClick={() => setPreviewTab(previewMode)}
key={previewMode?.uid}
>
{previewMode?.name}
</div>
));
}, [allowedPreviewModes, previewTab]);
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
const queryFilterEnabled = useMemo(() => codeMirrorMode.includes('json') && selectedFormat === 'json' && selectedTab === 'editor', [codeMirrorMode, selectedFormat, selectedTab]);
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
return (
@@ -122,9 +198,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
className="w-full h-full relative flex"
queryFilterEnabled={queryFilterEnabled}
>
<div className="flex justify-end gap-2 text-xs" role="tablist">
{tabs}
</div>
{error ? (
<div>
{hasScriptError ? null : (
@@ -147,21 +220,23 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
) : (
<div className="h-full flex flex-col">
<div className="flex-1 relative">
<QueryResultPreview
previewTab={previewTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
mode={mode}
collection={collection}
allowedPreviewModes={allowedPreviewModes}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
<div className="absolute top-0 left-0 h-full w-full" data-testid="response-preview-container">
<QueryResultPreview
selectedTab={selectedTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
previewMode={previewMode}
codeMirrorMode={codeMirrorMode}
collection={collection}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
</div>
{queryFilterEnabled && (
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={mode} />
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={codeMirrorMode} />
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { IconDots } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseDownload from 'src/components/ResponsePane/ResponseDownload';
const ResponseActions = ({ collection, item }) => {
const menuDropdownTippyRef = useRef();
@@ -26,7 +26,7 @@ const ResponseActions = ({ collection, item }) => {
<StyledWrapper className="ml-2 flex items-center">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-end">
<ResponseClear item={item} collection={collection} asDropdownItem onClose={handleClose} />
<ResponseSave item={item} asDropdownItem onClose={handleClose} />
<ResponseDownload item={item} asDropdownItem onClose={handleClose} />
</Dropdown>
</StyledWrapper>
);

View File

@@ -3,6 +3,13 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -24,27 +24,51 @@ const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
return 'Save current response as example';
};
const ResponseBookmark = ({ item, collection, responseSize }) => {
const ResponseBookmark = ({ item, collection, responseSize, children }) => {
const dispatch = useDispatch();
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
const response = item.response || {};
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
const isStreamingResponse = response.stream;
const isDisabled = isResponseTooLarge || isStreamingResponse;
// Only show for HTTP requests
if (item.type !== 'http-request') {
return null;
}
const handleSaveClick = () => {
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSaveClick(e);
}
};
const handleSaveClick = (e) => {
if (!response || response.error) {
toast.error('No valid response to save as example');
e.preventDefault();
e.stopPropagation();
return;
}
if (isResponseTooLarge) {
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
e.preventDefault();
e.stopPropagation();
return;
}
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
@@ -116,21 +140,28 @@ const ResponseBookmark = ({ item, collection, responseSize }) => {
return (
<>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={handleSaveClick}
disabled={isResponseTooLarge || isStreamingResponse}
title={
disabledMessage
}
className={classnames('p-1', {
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
})}
data-testid="response-bookmark-btn"
>
<IconBookmark size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onKeyDown={handleKeyDown}
onClick={handleSaveClick}
title={
!children ? disabledMessage : (isDisabled ? disabledMessage : null)
}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
})}
data-testid="response-bookmark-btn"
>
{children ?? (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconBookmark size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
<CreateExampleModal
isOpen={showSaveResponseExampleModal}

View File

@@ -2,7 +2,13 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.requestTabPanel.responseStatus};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -4,11 +4,11 @@ import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
// Hook to get clear response function
export const useResponseClear = (item, collection) => {
const dispatch = useDispatch();
const clearResponse = () => {
if (onClose) onClose();
dispatch(
responseCleared({
itemUid: item.uid,
@@ -18,21 +18,29 @@ const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
);
};
if (asDropdownItem) {
return (
<div className="dropdown-item" onClick={clearResponse}>
<IconEraser size={16} strokeWidth={1.5} className="icon mr-2" />
Clear
</div>
);
}
return { clearResponse };
};
const ResponseClear = ({ collection, item, children }) => {
const { clearResponse } = useResponseClear(item, collection);
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearResponse();
}
};
return (
<StyledWrapper className="ml-2 flex items-center">
<button onClick={clearResponse} title="Clear response">
<IconEraser size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={clearResponse} title={!children ? 'Clear response' : null} onKeyDown={handleKeyDown} data-testid="response-clear-button">
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconEraser size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
);
};
export default ResponseClear;

View File

@@ -2,7 +2,13 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: 0.8125rem;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -3,7 +3,8 @@ import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { IconCopy, IconCheck } from '@tabler/icons';
const ResponseCopy = ({ item }) => {
// Hook to get copy response function
export const useResponseCopy = (item) => {
const response = item.response || {};
const [copied, setCopied] = useState(false);
@@ -30,16 +31,39 @@ const ResponseCopy = ({ item }) => {
}
};
return { copyResponse, copied, hasData: !!response.data };
};
const ResponseCopy = ({ item, children }) => {
const { copyResponse, copied, hasData } = useResponseCopy(item);
const handleKeyDown = (e) => {
if ((e.key === 'Enter' || e.key === ' ') && hasData) {
e.preventDefault();
copyResponse();
}
};
const handleClick = () => {
if (hasData) {
copyResponse();
}
};
return (
<StyledWrapper className="ml-2 flex items-center">
<button onClick={copyResponse} disabled={!response.data} title="Copy response to clipboard">
{copied ? (
<IconCheck size={16} strokeWidth={1.5} />
) : (
<IconCopy size={16} strokeWidth={1.5} />
)}
</button>
</StyledWrapper>
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={handleClick} title={!children ? 'Copy response to clipboard' : null} onKeyDown={handleKeyDown} data-testid="response-copy-btn">
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1" disabled={!hasData}>
{copied ? (
<IconCheck size={16} strokeWidth={2} />
) : (
<IconCopy size={16} strokeWidth={2} />
)}
</button>
</StyledWrapper>
)}
</div>
);
};

View File

@@ -0,0 +1,14 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconDownload } from '@tabler/icons';
import classnames from 'classnames';
const ResponseDownload = ({ item, children }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer;
const saveResponseToFile = () => {
if (isDisabled) {
return;
}
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);
});
});
};
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
saveResponseToFile();
}
};
return (
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onClick={saveResponseToFile}
onKeyDown={handleKeyDown}
title={!children ? 'Save response to file' : null}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
})}
>
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconDownload size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
);
};
export default ResponseDownload;

View File

@@ -8,7 +8,13 @@ const Wrapper = styled.div`
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
}
`;

View File

@@ -3,14 +3,14 @@ import { useDispatch, useSelector } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
const IconDockToBottom = () => {
export const IconDockToBottom = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
strokeWidth="2"
stroke="currentColor"
fill="none"
>
@@ -25,14 +25,14 @@ const IconDockToBottom = () => {
);
};
const IconDockToRight = () => {
export const IconDockToRight = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
strokeWidth="2"
stroke="currentColor"
fill="none"
>
@@ -48,7 +48,8 @@ const IconDockToRight = () => {
);
};
const ResponseLayoutToggle = () => {
// Hook to get orientation and toggle function
export const useResponseLayoutToggle = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
@@ -65,19 +66,42 @@ const ResponseLayoutToggle = () => {
dispatch(savePreferences(updatedPreferences));
};
return { orientation, toggleOrientation };
};
const ResponseLayoutToggle = ({ children }) => {
const { orientation, toggleOrientation } = useResponseLayoutToggle();
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleOrientation();
}
};
const title = !children ? (orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout') : null;
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>
<div
role="button"
tabIndex={0}
onClick={toggleOrientation}
title={title}
onKeyDown={handleKeyDown}
data-testid="response-layout-toggle-button"
>
{children ? children : (
<StyledWrapper className="flex items-center w-full">
<button className="p-1">
{orientation === 'horizontal' ? (
<IconDockToBottom />
) : (
<IconDockToRight />
)}
</button>
</StyledWrapper>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More