mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 02:18:32 +00:00
Compare commits
53 Commits
v3.5.1
...
feat/fix-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8bc459ce | ||
|
|
a93e1dc8bf | ||
|
|
4fffef51ba | ||
|
|
6136d3ac62 | ||
|
|
942f995717 | ||
|
|
82ee8e1331 | ||
|
|
6711ccdda2 | ||
|
|
a7efed674e | ||
|
|
5345cb7b5f | ||
|
|
36e59e992c | ||
|
|
fab18d9e3e | ||
|
|
7b94e069e9 | ||
|
|
ba063f6d82 | ||
|
|
c857d27415 | ||
|
|
3c576487c9 | ||
|
|
277845b6d8 | ||
|
|
1907b2b3f0 | ||
|
|
05ab2661fa | ||
|
|
07c7348666 | ||
|
|
b73bf9d898 | ||
|
|
9d8c0fd2a0 | ||
|
|
2bc735ee00 | ||
|
|
1472f6b158 | ||
|
|
d8d468f1e0 | ||
|
|
0d73e38515 | ||
|
|
cff1f25528 | ||
|
|
db195fe302 | ||
|
|
e7e6cdfa51 | ||
|
|
7a24b1924d | ||
|
|
13363d7931 | ||
|
|
1d3a412539 | ||
|
|
59b4a16b79 | ||
|
|
377cdb488c | ||
|
|
79504ed729 | ||
|
|
6791e0a674 | ||
|
|
ed5f5c21cf | ||
|
|
280b856869 | ||
|
|
216d8e7151 | ||
|
|
13a48a256f | ||
|
|
240826ebc1 | ||
|
|
6f47218a81 | ||
|
|
95c75c90c1 | ||
|
|
366d85b141 | ||
|
|
b9ee1ee523 | ||
|
|
2d4d4e4037 | ||
|
|
b9d8bdf2ec | ||
|
|
913214e96b | ||
|
|
f629c3dd20 | ||
|
|
b70bfb26d4 | ||
|
|
a8b938fe4c | ||
|
|
dadd69b02d | ||
|
|
8f80230708 | ||
|
|
026dbfb108 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno @vijayh-bruno
|
||||
|
||||
4
.github/workflows/tests-linux.yml
vendored
4
.github/workflows/tests-linux.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Linux)
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 240
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
sudo chown root node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Run playwright Tests
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: ubuntu
|
||||
|
||||
2
.github/workflows/tests-macos.yml
vendored
2
.github/workflows/tests-macos.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (macOS)
|
||||
timeout-minutes: 150
|
||||
timeout-minutes: 240
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
2
.github/workflows/tests-windows.yml
vendored
2
.github/workflows/tests-windows.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Windows)
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 240
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
442
package-lock.json
generated
442
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -32,6 +31,7 @@
|
||||
"@storybook/react-webpack5": "^10.1.10",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"globals": "^16.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.17.23",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
@@ -94,9 +94,9 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"axios":"1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"pbkdf2": "3.1.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
}))
|
||||
});
|
||||
|
||||
jest.mock('nanoid', () => {
|
||||
return {
|
||||
nanoid: () => {}
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
|
||||
@@ -38,6 +38,9 @@ export default defineConfig({
|
||||
dynamicImportMode: "eager",
|
||||
},
|
||||
},
|
||||
rules: [
|
||||
{ test: /\.md$/, type: 'asset/source' }
|
||||
]
|
||||
},
|
||||
ignoreWarnings: [
|
||||
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
|
||||
|
||||
298
packages/bruno-app/src/components/AIAssist/StyledWrapper.js
Normal file
298
packages/bruno-app/src/components/AIAssist/StyledWrapper.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 10;
|
||||
|
||||
.ai-assist-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
opacity: 1;
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.colors.accent}55;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-assist-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 999px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-error {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-hint {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.popup-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid ${(props) => props.theme.input.border};
|
||||
border-top-color: ${(props) => props.theme.colors.accent};
|
||||
border-radius: 50%;
|
||||
animation: ai-assist-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ai-assist-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.preview-modes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.preview-mode-btn {
|
||||
padding: 2px 6px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
232
packages/bruno-app/src/components/AIAssist/index.js
Normal file
232
packages/bruno-app/src/components/AIAssist/index.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { IconStars, IconX, IconArrowBackUp } from '@tabler/icons';
|
||||
import { aiGenerateScript } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SUGGESTIONS = {
|
||||
'tests': [
|
||||
{ label: 'Status 200', prompt: 'Add a test asserting the response status code is 200' },
|
||||
{ label: 'JSON body', prompt: 'Add tests validating the JSON response body structure and key fields' },
|
||||
{ label: 'Headers', prompt: 'Add a test checking the content-type response header' },
|
||||
{ label: 'Response time', prompt: 'Add a test asserting the response time is below 1000ms' }
|
||||
],
|
||||
'pre-request': [
|
||||
{ label: 'Auth header', prompt: 'Set an Authorization header from an environment token variable' },
|
||||
{ label: 'Timestamp', prompt: 'Set a variable named "timestamp" containing the current epoch ms' },
|
||||
{ label: 'Random ID', prompt: 'Set a variable named "requestId" containing a random UUID-style id' }
|
||||
],
|
||||
'post-response': [
|
||||
{ label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' },
|
||||
{ label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' },
|
||||
{ label: 'Log response', prompt: 'Log the response status and a short summary of the body' }
|
||||
]
|
||||
};
|
||||
|
||||
const TITLES = {
|
||||
'tests': 'Generate Tests',
|
||||
'pre-request': 'Generate Pre-Request Script',
|
||||
'post-response': 'Generate Post-Response Script'
|
||||
};
|
||||
|
||||
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
|
||||
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [generated, setGenerated] = useState(null);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
const focusOnMount = useCallback((el) => {
|
||||
el?.focus();
|
||||
}, []);
|
||||
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isAiEnabled = get(preferences, 'ai.enabled', false);
|
||||
|
||||
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
|
||||
const title = TITLES[scriptType] || 'Generate with AI';
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const attachPopup = useCallback((el) => {
|
||||
if (!el) return undefined;
|
||||
const onDocMouseDown = (e) => {
|
||||
if (!el.contains(e.target) && !buttonRef.current?.contains(e.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('mousedown', onDocMouseDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (overridePrompt) => {
|
||||
const text = (overridePrompt ?? prompt).trim();
|
||||
if (!text || isLoading) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await aiGenerateScript({
|
||||
scriptType,
|
||||
prompt: text,
|
||||
currentScript: currentScript || '',
|
||||
requestContext
|
||||
});
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result?.content) {
|
||||
setGenerated(result.content);
|
||||
} else {
|
||||
setError('No content was generated. Try rephrasing your prompt.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to generate script');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (generated == null) return;
|
||||
onApply(generated);
|
||||
setGenerated(null);
|
||||
setPrompt('');
|
||||
close();
|
||||
}, [generated, onApply, close]);
|
||||
|
||||
const handleBackToPrompt = useCallback(() => {
|
||||
setGenerated(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
if (!isAiEnabled || !isValidType(scriptType)) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
title={title}
|
||||
type="button"
|
||||
aria-label={title}
|
||||
>
|
||||
<IconStars size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div ref={attachPopup} className="ai-assist-popup" role="dialog" aria-label={title}>
|
||||
<div className="popup-header">
|
||||
<span className="popup-title">
|
||||
<IconStars size={12} strokeWidth={1.75} />
|
||||
{title}
|
||||
</span>
|
||||
<button className="popup-close" onClick={close} type="button" aria-label="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generated == null ? (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<textarea
|
||||
ref={focusOnMount}
|
||||
className="popup-input"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe what you want to generate..."
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{!isLoading && !prompt && suggestions.length > 0 && (
|
||||
<div className="popup-suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
className="suggestion-chip"
|
||||
type="button"
|
||||
onClick={() => handleGenerate(s.prompt)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="popup-error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
{isLoading ? (
|
||||
<span className="popup-loading">
|
||||
<span className="loading-spinner" />
|
||||
Generating...
|
||||
</span>
|
||||
) : (
|
||||
<span className="popup-hint">⌘ + Enter to generate</span>
|
||||
)}
|
||||
<button
|
||||
className="btn-generate"
|
||||
type="button"
|
||||
onClick={() => handleGenerate()}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<div className="preview-section">
|
||||
<span className="preview-label">Preview · replaces current script</span>
|
||||
<pre className="preview-code">{generated}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<IconArrowBackUp size={12} /> Back
|
||||
</span>
|
||||
</button>
|
||||
<button className="btn-generate" type="button" onClick={handleApply}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAssist;
|
||||
@@ -0,0 +1,7 @@
|
||||
# What's New in Bruno
|
||||
|
||||
- Various stability and performance improvements.
|
||||
|
||||
---
|
||||
|
||||
For the full release history, see the [Bruno releases page](https://github.com/usebruno/bruno/releases).
|
||||
@@ -0,0 +1,31 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.changelog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs?.border || props.theme.sidebar?.border || 'transparent'};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.header-version {
|
||||
font-size: ${(props) => props.theme.font?.size?.sm || '0.85em'};
|
||||
color: ${(props) => props.theme.colors?.text?.muted || props.theme.text};
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.5rem 2rem 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
23
packages/bruno-app/src/components/ChangelogTab/index.js
Normal file
23
packages/bruno-app/src/components/ChangelogTab/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { IconConfetti } from '@tabler/icons';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import { version } from '../../../package.json';
|
||||
import changelogContent from './CHANGELOG.md';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ChangelogTab = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="changelog-header">
|
||||
<IconConfetti size={18} strokeWidth={1.5} />
|
||||
<span>What's New</span>
|
||||
<span className="header-version">v{version}</span>
|
||||
</div>
|
||||
<div className="changelog-body">
|
||||
<Markdown content={changelogContent} onDoubleClick={() => {}} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangelogTab;
|
||||
@@ -165,6 +165,32 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
|
||||
}
|
||||
|
||||
@keyframes cm-error-line-flash {
|
||||
0%, 60% {
|
||||
background-color: ${(props) => props.theme.status.danger.background};
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror .cm-error-line-flash {
|
||||
background-color: transparent;
|
||||
animation: cm-error-line-flash 3s ease-in-out;
|
||||
}
|
||||
|
||||
.CodeMirror .cm-error-line-flash-gutter {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.CodeMirror .cm-error-line-flash {
|
||||
animation: none;
|
||||
background-color: ${(props) => props.theme.status.danger.background};
|
||||
}
|
||||
}
|
||||
|
||||
.cm-search-match {
|
||||
background: rgba(255, 193, 7, 0.25);
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ const AuthMode = ({ collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -5,10 +5,11 @@ import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { get } from 'lodash';
|
||||
import Button from 'ui/Button';
|
||||
import { DEFAULT_PRESET_REQUEST_TYPE, PRESET_REQUEST_TYPES } from 'utils/common/constants';
|
||||
|
||||
const PresetsSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const initialPresets = { requestType: 'http', requestUrl: '' };
|
||||
const initialPresets = { requestType: DEFAULT_PRESET_REQUEST_TYPE, requestUrl: '' };
|
||||
|
||||
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
|
||||
const currentPresets = collection.draft?.brunoConfig
|
||||
@@ -47,12 +48,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="http"
|
||||
data-testid="presets-request-type-http"
|
||||
className="cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="http"
|
||||
checked={(currentPresets.requestType || 'http') === 'http'}
|
||||
value={PRESET_REQUEST_TYPES.HTTP}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.HTTP}
|
||||
/>
|
||||
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
|
||||
HTTP
|
||||
@@ -60,12 +62,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="graphql"
|
||||
data-testid="presets-request-type-graphql"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="graphql"
|
||||
checked={(currentPresets.requestType || 'http') === 'graphql'}
|
||||
value={PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
/>
|
||||
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
|
||||
GraphQL
|
||||
@@ -73,12 +76,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="grpc"
|
||||
data-testid="presets-request-type-grpc"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="grpc"
|
||||
checked={(currentPresets.requestType || 'http') === 'grpc'}
|
||||
value={PRESET_REQUEST_TYPES.GRPC}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRPC}
|
||||
/>
|
||||
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
|
||||
gRPC
|
||||
@@ -86,12 +90,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="ws"
|
||||
data-testid="presets-request-type-ws"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="ws"
|
||||
checked={(currentPresets.requestType || 'http') === 'ws'}
|
||||
value={PRESET_REQUEST_TYPES.WS}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.WS}
|
||||
/>
|
||||
<label htmlFor="ws" className="ml-1 cursor-pointer select-none">
|
||||
WebSocket
|
||||
@@ -106,6 +111,7 @@ const PresetsSettings = ({ collection }) => {
|
||||
<div className="flex items-center flex-grow input-container h-full">
|
||||
<input
|
||||
id="request-url"
|
||||
data-testid="presets-request-url"
|
||||
type="text"
|
||||
name="requestUrl"
|
||||
placeholder="Request URL"
|
||||
@@ -123,7 +129,7 @@ const PresetsSettings = ({ collection }) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="button" size="sm" onClick={handleSave}>
|
||||
<Button type="button" size="sm" data-testid="presets-save-btn" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -13,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Script = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -59,6 +61,20 @@ const Script = ({ collection }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: preRequestEditorRef,
|
||||
scriptPhase: 'pre-request',
|
||||
isVisible: activeTab === 'pre-request'
|
||||
});
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: postResponseEditorRef,
|
||||
scriptPhase: 'post-response',
|
||||
isVisible: activeTab === 'post-response'
|
||||
});
|
||||
|
||||
const onRequestScriptEdit = (value) => {
|
||||
dispatch(
|
||||
updateCollectionRequestScript({
|
||||
@@ -108,39 +124,53 @@ const Script = ({ collection }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Tests = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -29,24 +31,33 @@ const Tests = ({ collection }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: testsEditorRef,
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -57,15 +59,31 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -80,6 +98,7 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="collection-vars"
|
||||
testId={`collection-vars-${varType === 'response' ? 'res' : 'req'}`}
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
|
||||
@@ -15,6 +15,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import Vars from './Vars/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import Overview from './Overview/index';
|
||||
import { DEFAULT_PRESET_REQUEST_TYPE } from 'utils/common/constants';
|
||||
|
||||
const CollectionSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -60,7 +61,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
? get(collection, 'draft.brunoConfig.protobuf', {})
|
||||
: get(collection, 'brunoConfig.protobuf', {});
|
||||
const presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', {}) : get(collection, 'brunoConfig.presets', {});
|
||||
const hasPresets = presets && presets.requestUrl !== '';
|
||||
const hasPresets = presets && ((presets.requestType && presets.requestType !== DEFAULT_PRESET_REQUEST_TYPE) || (presets.requestUrl && presets.requestUrl !== ''));
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.type-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.caret-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
58
packages/bruno-app/src/components/DataTypeSelector/index.js
Normal file
58
packages/bruno-app/src/components/DataTypeSelector/index.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { IconAlertCircle, IconCaretDown } from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { BRUNO_VARIABLE_DATATYPES, parseValueByDataType, validateDataTypeValue } from '@usebruno/common/utils';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DataTypeSelector = ({ variable, onChange }) => {
|
||||
const selectedType = variable.dataType || 'string';
|
||||
const coercedValue = parseValueByDataType(variable.value, selectedType);
|
||||
const typeError = validateDataTypeValue(coercedValue, selectedType);
|
||||
|
||||
const handleTypeChange = (type) => {
|
||||
onChange({ dataType: type === 'string' ? undefined : type });
|
||||
};
|
||||
|
||||
const items = BRUNO_VARIABLE_DATATYPES.map((type) => ({
|
||||
id: type,
|
||||
label: type,
|
||||
onClick: () => handleTypeChange(type)
|
||||
}));
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center relative">
|
||||
<MenuDropdown
|
||||
items={items}
|
||||
selectedItemId={selectedType}
|
||||
placement="bottom-end"
|
||||
showTickMark={true}
|
||||
appendTo={() => document.body}
|
||||
>
|
||||
<div className="flex items-center cursor-pointer select-none">
|
||||
<span className="type-label">{selectedType}</span>
|
||||
<IconCaretDown className="caret-icon ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
{typeError && (
|
||||
<span className="ml-1">
|
||||
<IconAlertCircle
|
||||
data-tooltip-id={`type-error-${variable.uid}`}
|
||||
className="text-yellow-600 cursor-pointer"
|
||||
size={16}
|
||||
/>
|
||||
<Tooltip
|
||||
className="tooltip-mod"
|
||||
id={`type-error-${variable.uid}`}
|
||||
content={typeError}
|
||||
place="top"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DataTypeSelector);
|
||||
@@ -69,13 +69,22 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.col-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${(props) => props.theme.console.border};
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 4px 16px;
|
||||
padding: 0;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 10px;
|
||||
@@ -83,6 +92,39 @@ const StyledWrapper = styled.div`
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
@@ -94,9 +136,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.request-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 2px 16px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
@@ -107,12 +147,19 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.selected {
|
||||
padding-left: 13px;
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
|
||||
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.request-method {
|
||||
padding: 2px 8px 2px 16px;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -128,6 +175,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-domain {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -135,6 +183,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-path {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -143,19 +192,26 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-time {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
}
|
||||
|
||||
.request-duration {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.request-size {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconNetwork
|
||||
IconNetwork,
|
||||
IconArrowUp,
|
||||
IconArrowDown
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
setSelectedRequest
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils';
|
||||
|
||||
// TODO: Columns will be resizable in the future, so width can be null (for auto) or a number (for fixed width)
|
||||
const COLUMNS = [
|
||||
{ key: 'method', label: 'Method', width: 90, align: 'left' },
|
||||
{ key: 'status', label: 'Status', width: 80, align: 'left' },
|
||||
{ key: 'domain', label: 'Domain', width: 200, align: 'left' },
|
||||
{ key: 'path', label: 'Path', width: null, align: 'left' },
|
||||
{ key: 'time', label: 'Time', width: 100, align: 'left' },
|
||||
{ key: 'duration', label: 'Duration', width: 120, align: 'right' },
|
||||
{ key: 'size', label: 'Size', width: 80, align: 'right' }
|
||||
];
|
||||
|
||||
const MethodBadge = ({ method }) => {
|
||||
const methodLower = method?.toLowerCase() || 'get';
|
||||
@@ -28,7 +42,7 @@ const StatusBadge = ({ status, statusCode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
|
||||
const { data } = request;
|
||||
const { request: req, response: res, timestamp } = data;
|
||||
|
||||
@@ -82,6 +96,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
<div
|
||||
className={`request-row ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onClick}
|
||||
style={{ gridTemplateColumns }}
|
||||
data-testid="network-request-row"
|
||||
|
||||
>
|
||||
<div className="request-method">
|
||||
<MethodBadge method={req?.method} />
|
||||
@@ -116,6 +133,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
|
||||
const NetworkTab = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
|
||||
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
|
||||
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
|
||||
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
@@ -150,6 +170,21 @@ const NetworkTab = () => {
|
||||
dispatch(setSelectedRequest(request));
|
||||
};
|
||||
|
||||
const handleHeaderClick = (key) => {
|
||||
setSortConfig((prev) => {
|
||||
// If clicking a different column, start with ascending sort
|
||||
if (prev.key !== key) return { key, direction: 'asc' };
|
||||
|
||||
if (prev.direction === 'asc') return { key, direction: 'desc' };
|
||||
return { key: null, direction: null };
|
||||
});
|
||||
};
|
||||
|
||||
const sortedRequests = useMemo(
|
||||
() => sortRequests(filteredRequests, sortConfig.key, sortConfig.direction),
|
||||
[filteredRequests, sortConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="network-content">
|
||||
@@ -161,26 +196,45 @@ const NetworkTab = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="requests-container">
|
||||
<div className="requests-header">
|
||||
<div>Method</div>
|
||||
<div>Status</div>
|
||||
<div>Domain</div>
|
||||
<div>Path</div>
|
||||
<div>Time</div>
|
||||
<div className="text-right">Duration</div>
|
||||
<div className="text-right">Size</div>
|
||||
<div className="requests-header" style={{ gridTemplateColumns }}>
|
||||
{COLUMNS.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`header-cell${col.align === 'right' ? ' text-right' : ''}`}
|
||||
onClick={() => handleHeaderClick(col.key)}
|
||||
data-testid={`network-header-${col.key}`}
|
||||
>
|
||||
<span title={col.label}>{col.label}</span>
|
||||
{sortConfig.key === col.key && (
|
||||
sortConfig.direction === 'asc'
|
||||
? <IconArrowUp size={14} strokeWidth={2} data-testid="sort-icon-asc" />
|
||||
: <IconArrowDown size={14} strokeWidth={2} data-testid="sort-icon-desc" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{filteredRequests.map((request, index) => (
|
||||
{sortedRequests.map((request, index) => (
|
||||
<RequestRow
|
||||
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
|
||||
request={request}
|
||||
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
|
||||
onClick={() => handleRequestClick(request)}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{separatorPositions.map((pos, i) =>
|
||||
pos ? (
|
||||
<div
|
||||
key={i}
|
||||
className="col-separator"
|
||||
style={'left' in pos ? { left: `${pos.left}px` } : { right: `${pos.right}px` }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { ThemeProvider } from 'providers/Theme';
|
||||
import NetworkTab from './index';
|
||||
|
||||
const makeRequest = (overrides = {}) => ({
|
||||
type: 'request',
|
||||
timestamp: overrides.timestamp ?? 1000,
|
||||
collectionUid: overrides.collectionUid ?? 'col-1',
|
||||
itemUid: overrides.itemUid ?? 'item-1',
|
||||
collectionName: 'Test Collection',
|
||||
data: {
|
||||
request: {
|
||||
method: overrides.method ?? 'GET',
|
||||
url: overrides.url ?? 'https://example.com/api/users'
|
||||
},
|
||||
response: {
|
||||
status: overrides.status ?? 200,
|
||||
statusCode: overrides.statusCode ?? 200,
|
||||
// Use 'in' check so callers can explicitly pass undefined to test missing-value behaviour
|
||||
...('duration' in overrides ? { duration: overrides.duration } : { duration: 100 }),
|
||||
...('size' in overrides ? { size: overrides.size } : { size: 512 })
|
||||
},
|
||||
timestamp: overrides.timestamp ?? 1000
|
||||
}
|
||||
});
|
||||
|
||||
const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
|
||||
|
||||
const renderNetworkTab = (requests = []) => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
collections: (state = {
|
||||
collections: [{
|
||||
uid: 'col-1',
|
||||
name: 'Test Collection',
|
||||
timeline: requests
|
||||
}]
|
||||
}) => state,
|
||||
logs: (state = {
|
||||
networkFilters: ALL_FILTERS,
|
||||
selectedRequest: null
|
||||
}) => state
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
<NetworkTab />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('sort state cycle', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: 'a', method: 'GET' }),
|
||||
makeRequest({ itemUid: 'b', method: 'POST' })
|
||||
];
|
||||
|
||||
it('shows no sort icon by default', () => {
|
||||
renderNetworkTab(requests);
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('first click on a column shows ascending icon', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('second click on same column shows descending icon', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('third click on same column clears sort', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a different column resets to ascending on the new column', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method')); // now desc
|
||||
fireEvent.click(screen.getByTestId('network-header-status')); // switch column
|
||||
// Should show asc on status, not desc
|
||||
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sort icon only appears on the active column', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-duration'));
|
||||
// Only one icon total
|
||||
expect(screen.getAllByTestId('sort-icon-asc')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort results', () => {
|
||||
const getRowMethods = () =>
|
||||
screen.getAllByTestId('network-request-row').map((row) =>
|
||||
row.querySelector('.method-badge')?.textContent
|
||||
);
|
||||
|
||||
it('sorts by method ascending (A → Z)', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
|
||||
});
|
||||
|
||||
it('sorts by method descending (Z → A)', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
});
|
||||
|
||||
it('sorts by status ascending', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', statusCode: 500 }),
|
||||
makeRequest({ itemUid: '2', statusCode: 200 }),
|
||||
makeRequest({ itemUid: '3', statusCode: 404 })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-status'));
|
||||
const rows = screen.getAllByTestId('network-request-row');
|
||||
const statuses = rows.map((r) => r.querySelector('.status-badge')?.textContent);
|
||||
expect(statuses).toEqual(['200', '404', '500']);
|
||||
});
|
||||
|
||||
it('sorts mixed-case methods case-insensitively', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'post' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'delete' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
// MethodBadge always renders uppercase; sort order should treat 'post' == 'POST'
|
||||
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
|
||||
});
|
||||
|
||||
it('preserves insertion order when sort is cleared', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
// Sort then clear
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
export const getGridTemplate = (columns) =>
|
||||
columns.map((c) => (c.width ? `${c.width}px` : '1fr')).join(' ');
|
||||
|
||||
export const getSeparatorPositions = (columns) => {
|
||||
const n = columns.length;
|
||||
const positions = new Array(n - 1).fill(null);
|
||||
|
||||
let leftOffset = 0;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (columns[i].width === null) break;
|
||||
leftOffset += columns[i].width;
|
||||
positions[i] = { left: leftOffset };
|
||||
}
|
||||
|
||||
let rightOffset = 0;
|
||||
for (let i = n - 1; i > 0; i--) {
|
||||
if (columns[i].width === null) break;
|
||||
rightOffset += columns[i].width;
|
||||
if (positions[i - 1] === null) {
|
||||
positions[i - 1] = { right: rightOffset };
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
};
|
||||
|
||||
export const getSortValue = (request, key) => {
|
||||
const { request: req, response: res, timestamp } = request.data;
|
||||
switch (key) {
|
||||
case 'method': return req?.method?.toUpperCase() ?? '';
|
||||
case 'status': return res?.statusCode || res?.status || 0;
|
||||
case 'domain': {
|
||||
try { return new URL(req?.url || '').hostname; } catch { return req?.url || ''; }
|
||||
}
|
||||
case 'path': {
|
||||
try {
|
||||
const u = new URL(req?.url || '');
|
||||
return u.pathname + u.search;
|
||||
} catch { return req?.url || ''; }
|
||||
}
|
||||
case 'time': return timestamp || 0;
|
||||
case 'duration': return res?.duration || 0;
|
||||
case 'size': return res?.size || 0;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const sortRequests = (requests, key, direction) => {
|
||||
if (!key || !direction) return requests;
|
||||
return [...requests].sort((a, b) => {
|
||||
const valueA = getSortValue(a, key);
|
||||
const valueB = getSortValue(b, key);
|
||||
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
|
||||
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
@@ -4,11 +4,8 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
border-left: 1px solid ${(props) => props.theme.console.border};
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-header {
|
||||
|
||||
@@ -144,6 +144,41 @@ const StyledWrapper = styled.div`
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.details-panel-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
div.details-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
width: 6px;
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
div.drag-request-border {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.drag-request-border {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.action-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
setActiveTab,
|
||||
clearDebugErrors,
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters
|
||||
toggleAllNetworkFilters,
|
||||
updateRequestDetailsPanelWidth
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
|
||||
import NetworkTab from './NetworkTab';
|
||||
@@ -33,6 +34,10 @@ import RequestDetailsPanel from './RequestDetailsPanel';
|
||||
import ErrorDetailsPanel from './ErrorDetailsPanel';
|
||||
import Performance from '../Performance';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useResizablePanel } from 'hooks/useResizablePanel';
|
||||
|
||||
const MIN_DETAILS_PANEL_WIDTH = 280;
|
||||
const MAX_DETAILS_PANEL_WIDTH = 800;
|
||||
|
||||
const LogIcon = ({ type }) => {
|
||||
const iconProps = { size: 16, strokeWidth: 1.5 };
|
||||
@@ -381,8 +386,17 @@ const Console = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const savedDetailsPanelWidth = useSelector((state) => state.logs.requestDetailsPanelWidth);
|
||||
const consoleRef = useRef(null);
|
||||
|
||||
const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({
|
||||
initialWidth: savedDetailsPanelWidth,
|
||||
minWidth: MIN_DETAILS_PANEL_WIDTH,
|
||||
maxWidth: MAX_DETAILS_PANEL_WIDTH,
|
||||
direction: 'right',
|
||||
onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth }))
|
||||
});
|
||||
|
||||
const logCounts = logs.reduce((counts, log) => {
|
||||
counts[log.type] = (counts[log.type] || 0) + 1;
|
||||
return counts;
|
||||
@@ -614,7 +628,16 @@ const Console = () => {
|
||||
<div className="network-main">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
<RequestDetailsPanel />
|
||||
<div className="details-panel-wrapper" style={{ width: detailsPanelWidth }}>
|
||||
<div
|
||||
className="details-drag-handle"
|
||||
onMouseDown={handleDetailsPanelDragStart}
|
||||
data-testid="details-panel-drag-handle"
|
||||
>
|
||||
<div className="drag-request-border" />
|
||||
</div>
|
||||
<RequestDetailsPanel />
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'debug' && selectedError ? (
|
||||
<div className="debug-with-details">
|
||||
|
||||
@@ -21,17 +21,19 @@ const findScrollParent = (element) => {
|
||||
const TableRow = React.memo(
|
||||
({ children, item, context, ...rest }) => {
|
||||
const rowIndex = Number(rest['data-item-index']);
|
||||
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
|
||||
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave, keyColumn } = context;
|
||||
const isEmpty = isLastEmptyRow(item, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
const isDragOver = canDrag && dragOverRow === rowIndex;
|
||||
const existingClass = rest.className || '';
|
||||
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
|
||||
const rowName = keyColumn ? item?.[keyColumn.key] : undefined;
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...rest}
|
||||
className={className}
|
||||
data-row-name={rowName || undefined}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
|
||||
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
|
||||
@@ -168,6 +170,17 @@ const EditableTable = ({
|
||||
};
|
||||
}, [defaultRow, checkboxKey]);
|
||||
|
||||
const hasAnyValue = useCallback((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;
|
||||
}, [columns, defaultRow]);
|
||||
|
||||
const rowsWithEmpty = useMemo(() => {
|
||||
if (!showAddRow) {
|
||||
return rows;
|
||||
@@ -177,16 +190,11 @@ const EditableTable = ({
|
||||
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 the last row is already empty (e.g. a stray empty row loaded from a
|
||||
// pre-existing file), don't append another one — otherwise the table would
|
||||
// render two empty rows at the bottom on the initial render.
|
||||
if (!hasAnyValue(rows[rows.length - 1])) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
|
||||
@@ -198,15 +206,11 @@ const EditableTable = ({
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
}];
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, hasAnyValue, 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]);
|
||||
// A row is empty when none of its columns hold a value — the single source of
|
||||
// truth used everywhere (memo guard, persistence filter, last-row rendering).
|
||||
const isEmptyRow = useCallback((row) => !hasAnyValue(row), [hasAnyValue]);
|
||||
|
||||
const isLastEmptyRow = useCallback((row, index) => {
|
||||
if (!showAddRow) return false;
|
||||
@@ -227,50 +231,20 @@ const EditableTable = ({
|
||||
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) => {
|
||||
const 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;
|
||||
});
|
||||
// Remove any fully-empty rows from the persisted data. The trailing empty
|
||||
// "add row" is re-added by the rowsWithEmpty memo, so there's always
|
||||
// exactly one empty row at the bottom and never a stray empty row above it.
|
||||
const result = showAddRow ? updatedRows.filter(hasAnyValue) : updatedRows;
|
||||
|
||||
onChange(result);
|
||||
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
|
||||
}, [rowsWithEmpty, hasAnyValue, onChange, showAddRow]);
|
||||
|
||||
const handleCheckboxChange = useCallback((rowUid, checked) => {
|
||||
handleValueChange(rowUid, checkboxKey, checked);
|
||||
@@ -370,17 +344,20 @@ const EditableTable = ({
|
||||
);
|
||||
}, [isLastEmptyRow, getRowError, handleValueChange]);
|
||||
|
||||
const keyColumn = useMemo(() => columns.find((col) => col.isKeyField), [columns]);
|
||||
|
||||
const virtuosoContext = useMemo(() => ({
|
||||
reorderable,
|
||||
reorderableRowCount,
|
||||
isLastEmptyRow,
|
||||
dragOverRow,
|
||||
keyColumn,
|
||||
onDragStart: handleDragStart,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
onDragEnd: handleDragEnd
|
||||
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
|
||||
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, keyColumn, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
|
||||
|
||||
const fixedHeaderContent = useCallback(() => (
|
||||
<tr>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { IconTrash, IconAlertCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { BRUNO_VARIABLE_DATATYPES, valueToString } from '@usebruno/common/utils';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
@@ -23,14 +25,17 @@ const MIN_COLUMN_WIDTH = 80;
|
||||
const MIN_ROW_HEIGHT = 35;
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item, style, ...rest }) => (
|
||||
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
({ children, item, style, ...rest }) => {
|
||||
const variable = item?.variable ?? item;
|
||||
return (
|
||||
<tr key={variable?.uid} style={style} {...rest} data-testid={`env-var-row-${variable?.name}`}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
const prevUid = prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.uid;
|
||||
const prevUid = prevProps?.item?.variable?.uid ?? prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.variable?.uid ?? nextProps?.item?.uid;
|
||||
return prevUid === nextUid && prevProps.children === nextProps.children;
|
||||
}
|
||||
);
|
||||
@@ -203,7 +208,9 @@ const EnvironmentVariablesTable = ({
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable()
|
||||
value: Yup.mixed().nullable(),
|
||||
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable(),
|
||||
annotations: Yup.array().nullable()
|
||||
})
|
||||
),
|
||||
validate: (values) => {
|
||||
@@ -391,8 +398,16 @@ const EnvironmentVariablesTable = ({
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
// Compare without UIDs since they can be different but the actual data is the same
|
||||
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
|
||||
// Compare against what's on disk: for an ephemeral overlay, that's
|
||||
// `persistedValue`, not the scripted value Redux is holding.
|
||||
const baselineForCompare = (v) => {
|
||||
const stripped = stripEnvVarUid(v);
|
||||
if (v?.ephemeral && v?.persistedValue !== undefined) {
|
||||
stripped.value = v.persistedValue;
|
||||
}
|
||||
return stripped;
|
||||
};
|
||||
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(baselineForCompare));
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
@@ -524,6 +539,7 @@ const EnvironmentVariablesTable = ({
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
defaultItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
@@ -569,21 +585,20 @@ const EnvironmentVariablesTable = ({
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
className="flex flex-row flex-nowrap items-center gap-2"
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div
|
||||
className="overflow-hidden grow w-full relative"
|
||||
className="flex-1 min-w-0 relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
value={valueToString(variable.value, 2)}
|
||||
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
@@ -608,13 +623,17 @@ const EnvironmentVariablesTable = ({
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
{!isLastEmptyRow && (
|
||||
<span>
|
||||
<DataTypeSelector
|
||||
variable={variable}
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
onChange={(fields) => {
|
||||
Object.entries(fields).forEach(([key, val]) => {
|
||||
formik.setFieldValue(`${actualIndex}.${key}`, val, true);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ const EnvironmentListContent = ({
|
||||
<span>No Environment</span>
|
||||
</div>
|
||||
<ToolHint
|
||||
anchorSelect="[data-tooltip-content]"
|
||||
tooltipId="environment-name-tooltip"
|
||||
place="right"
|
||||
positionStrategy="fixed"
|
||||
tooltipStyle={{
|
||||
@@ -40,6 +40,7 @@ const EnvironmentListContent = ({
|
||||
key={env.uid}
|
||||
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(env)}
|
||||
data-tooltip-id="environment-name-tooltip"
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,8 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
pre {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SaveFileErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Save File Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveFileErrorModal;
|
||||
@@ -0,0 +1,55 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: 100%;
|
||||
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-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;
|
||||
236
packages/bruno-app/src/components/FileEditor/CodeEditor/index.js
Normal file
236
packages/bruno-app/src/components/FileEditor/CodeEditor/index.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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 isEqual from 'lodash/isEqual';
|
||||
import { getEnvironmentVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
window.JSHINT = JSHINT;
|
||||
}
|
||||
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Keep a cached version of the value, this cache will be updated when the
|
||||
// editor is updated, which can later be used to protect the editor from
|
||||
// unnecessary updates during the update lifecycle.
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
expr: true,
|
||||
asi: true
|
||||
};
|
||||
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
mode: this.props.mode || 'application/ld+json',
|
||||
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();
|
||||
}
|
||||
},
|
||||
'Shift-Cmd-M': () => {
|
||||
if (this.props.toggleFileMode) {
|
||||
this.props.toggleFileMode();
|
||||
}
|
||||
},
|
||||
'Shift-Ctrl-M': () => {
|
||||
if (this.props.toggleFileMode) {
|
||||
this.props.toggleFileMode();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this._node.querySelector('.bruno-search-bar > input').focus();
|
||||
}
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
},
|
||||
'Ctrl-F': (cm) => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this._node.querySelector('.bruno-search-bar > input').focus();
|
||||
}
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
},
|
||||
'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',
|
||||
'Esc': () => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
editor.scrollTo(null, this.props.initialScroll);
|
||||
this._lastScrollTop = this.props.initialScroll || 0;
|
||||
editor.on('scroll', this._onScroll);
|
||||
this.addOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Ensure the changes caused by this update are not interpreted as
|
||||
// user-input changes which could otherwise result in an infinite
|
||||
// event loop.
|
||||
this.ignoreChangeEvent = true;
|
||||
if (this.props.schema !== prevProps.schema && this.editor) {
|
||||
this.editor.options.lint.schema = this.props.schema;
|
||||
this.editor.options.hintOptions.schema = this.props.schema;
|
||||
this.editor.options.info.schema = this.props.schema;
|
||||
this.editor.options.jump.schema = this.props.schema;
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
let variables = getEnvironmentVariables(this.props.collection);
|
||||
if (!isEqual(variables, this.variables)) {
|
||||
this.addOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
|
||||
if (this.props.initialScroll !== prevProps.initialScroll && this.editor) {
|
||||
this.editor.scrollTo(null, this.props.initialScroll);
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('scroll', this._onScroll);
|
||||
if (typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop || 0);
|
||||
}
|
||||
const editorElement = this.editor.getWrapperElement();
|
||||
if (editorElement && editorElement.parentNode) {
|
||||
editorElement.parentNode.removeChild(editorElement);
|
||||
}
|
||||
this.editor = null;
|
||||
this._node = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full"
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
>
|
||||
<CodeMirrorSearch
|
||||
visible={this.state.searchBarVisible}
|
||||
editor={this.editor}
|
||||
onClose={() => this.setState({ searchBarVisible: false })}
|
||||
/>
|
||||
<div
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
addOverlay = () => {
|
||||
const mode = this.props.mode || 'application/ld+json';
|
||||
let variables = getEnvironmentVariables(this.props.collection);
|
||||
this.variables = variables;
|
||||
|
||||
defineCodeMirrorBrunoVariablesMode(variables, mode);
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onScroll = () => {
|
||||
if (!this.editor) return;
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
if (wrapper && wrapper.offsetParent === null) return;
|
||||
this._lastScrollTop = this.editor.getScrollInfo().top;
|
||||
if (typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
};
|
||||
}
|
||||
68
packages/bruno-app/src/components/FileEditor/index.js
Normal file
68
packages/bruno-app/src/components/FileEditor/index.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from './CodeEditor/index';
|
||||
import { saveFile } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { toggleCollectionFileMode, updateFileContent } from 'providers/ReduxStore/slices/collections';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const FileEditor = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `file-mode-scroll-${item.uid}`, default: 0 });
|
||||
|
||||
const content = item.draft ? item.draft.raw : item.raw || '';
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
updateFileContent({
|
||||
content: value,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const hasChanges = item.draft != null;
|
||||
|
||||
const onSave = () => {
|
||||
if (!hasChanges) return;
|
||||
dispatch(saveFile(content, item?.uid, collection?.uid));
|
||||
};
|
||||
|
||||
const _toggleFileMode = () => {
|
||||
dispatch(toggleCollectionFileMode({ collectionUid: collection.uid }));
|
||||
};
|
||||
|
||||
const editorMode = item?.type == 'js' ? 'javascript' : item?.type == 'json' ? 'javascript' : 'application/text';
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow relative h-full">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
toggleFileMode={_toggleFileMode}
|
||||
mode={editorMode}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<IconDeviceFloppy
|
||||
onClick={onSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -18,8 +18,9 @@ import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import Button from 'ui/Button';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -52,41 +53,6 @@ const Auth = ({ collection, folder }) => {
|
||||
let request = get(folderRoot, 'request', {});
|
||||
const authMode = get(folderRoot, 'request.auth.mode');
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Get path from collection to current folder
|
||||
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
|
||||
|
||||
// Check parent folders to find closest auth configuration
|
||||
// Skip the last item which is the current folder
|
||||
for (let i = 0; i < folderTreePath.length - 1; i++) {
|
||||
const parentFolder = folderTreePath[i];
|
||||
if (parentFolder.type === 'folder') {
|
||||
const parentFolderRoot = parentFolder?.draft || parentFolder?.root;
|
||||
const folderAuth = get(parentFolderRoot, 'request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: parentFolder.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
@@ -98,6 +64,11 @@ const Auth = ({ collection, folder }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, folder) : null),
|
||||
[authMode, folder, collection]
|
||||
);
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'basic': {
|
||||
@@ -202,12 +173,11 @@ const Auth = ({ collection, folder }) => {
|
||||
);
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -81,14 +81,15 @@ const AuthMode = ({ collection, folder }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
showTickMark={true}
|
||||
data-testid="auth-mode-dropdown"
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -13,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Script = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -22,7 +24,9 @@ const Script = ({ collection, folder }) => {
|
||||
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
|
||||
const focusedTab = find(tabs, (tab) => tab.type === 'folder-settings' && (tab.uid === folder.uid || tab.folderUid === folder.uid))
|
||||
|| find(tabs, (tab) => tab.type === 'folder-settings' && tab.pathname === folder.pathname);
|
||||
const tabUid = focusedTab?.uid || folder.uid;
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
|
||||
@@ -34,7 +38,7 @@ const Script = ({ collection, folder }) => {
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
|
||||
dispatch(updateScriptPaneTab({ uid: tabUid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
@@ -60,6 +64,20 @@ const Script = ({ collection, folder }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: folder.uid,
|
||||
editorRef: preRequestEditorRef,
|
||||
scriptPhase: 'pre-request',
|
||||
isVisible: activeTab === 'pre-request'
|
||||
});
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: folder.uid,
|
||||
editorRef: postResponseEditorRef,
|
||||
scriptPhase: 'post-response',
|
||||
isVisible: activeTab === 'post-response'
|
||||
});
|
||||
|
||||
const onRequestScriptEdit = (value) => {
|
||||
dispatch(
|
||||
updateFolderRequestScript({
|
||||
@@ -111,39 +129,53 @@ const Script = ({ collection, folder }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Tests = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -30,24 +32,33 @@ const Tests = ({ collection, folder }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: folder.uid,
|
||||
editorRef: testsEditorRef,
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -62,16 +64,32 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -86,6 +104,7 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="folder-vars"
|
||||
testId={`folder-vars-${varType === 'response' ? 'res' : 'req'}`}
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -10,7 +10,7 @@ import Vars from './Vars';
|
||||
import Documentation from './Documentation';
|
||||
import Auth from './Auth';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import get from 'lodash/get';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
|
||||
const FolderSettings = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -31,8 +31,11 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
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';
|
||||
const folderAuthMode = folder?.draft?.request?.auth?.mode ?? folder?.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, folder),
|
||||
[folder, folderAuthMode, collection]
|
||||
);
|
||||
|
||||
const setTab = (tab) => {
|
||||
dispatch(
|
||||
@@ -95,7 +98,7 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <StatusDot />}
|
||||
{hasAuth && <StatusDot dataTestId="auth" />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import useFocusTrap from 'hooks/useFocusTrap';
|
||||
import Button from 'ui/Button';
|
||||
@@ -12,13 +13,15 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
|
||||
{handleCancel && !hideClose ? (
|
||||
// TODO: Remove data-test-id and use data-testid instead across the codebase.
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-testid="modal-close-button">
|
||||
×
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ModalContent = ({ children }) => <div className="bruno-modal-content px-4 py-4">{children}</div>;
|
||||
const ModalContent = ({ children, noPadding }) => (
|
||||
<div className={`bruno-modal-content ${noPadding ? '' : 'px-4 py-4'}`}>{children}</div>
|
||||
);
|
||||
|
||||
const ModalFooter = ({
|
||||
confirmText,
|
||||
@@ -84,7 +87,8 @@ const Modal = ({
|
||||
onClick,
|
||||
closeModalFadeTimeout = 500,
|
||||
dataTestId,
|
||||
confirmButtonColor = 'primary'
|
||||
confirmButtonColor = 'primary',
|
||||
noPadding
|
||||
}) => {
|
||||
const modalRef = useRef(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
@@ -148,7 +152,7 @@ const Modal = ({
|
||||
handleCancel={() => closeModal({ type: 'icon' })}
|
||||
customHeader={customHeader}
|
||||
/>
|
||||
<ModalContent>{children}</ModalContent>
|
||||
<ModalContent noPadding={noPadding}>{children}</ModalContent>
|
||||
<ModalFooter
|
||||
confirmText={confirmText}
|
||||
cancelText={cancelText}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { parseToRgb, rgba } from 'polished';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { humanizeDate } from 'utils/common';
|
||||
|
||||
// color may be any CSS color (hex, rgb, hsl): solid text on a 15% tinted bg.
|
||||
// Falls back to the theme's purple when the supplied color can't be parsed.
|
||||
export const getBadgeStyle = (color, theme) => {
|
||||
let badgeColor = theme.colors.text.purple;
|
||||
try {
|
||||
parseToRgb(color);
|
||||
badgeColor = color;
|
||||
} catch {
|
||||
// invalid color; keep the fallback
|
||||
}
|
||||
return {
|
||||
backgroundColor: rgba(badgeColor, 0.15),
|
||||
color: badgeColor
|
||||
};
|
||||
};
|
||||
|
||||
const getSanitizedDescription = (description) => {
|
||||
return DOMPurify.sanitize(description || '', {
|
||||
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'br', 'strong', 'em'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
|
||||
});
|
||||
};
|
||||
|
||||
const NotificationDetail = ({ notification }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Rendered in a sandboxed iframe (no allow-scripts); theme CSS is inlined
|
||||
// since the iframe doesn't inherit app styles.
|
||||
const buildDescriptionDocument = (description) => {
|
||||
const body = getSanitizedDescription(description);
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: ${theme.notifications.bg}; }
|
||||
body {
|
||||
padding: 8px 12px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: ${theme.colors.text.muted};
|
||||
word-break: break-word;
|
||||
}
|
||||
p { margin: 0 0 0.75rem 0; }
|
||||
a { color: ${theme.textLink}; text-decoration: underline; }
|
||||
h1, h2, h3, h4, h5, h6 { font-size: 13px; font-weight: 600; margin: 0 0 0.5rem 0; color: ${theme.text}; }
|
||||
ul { padding-left: 1.25rem; margin: 0 0 0.75rem 0; }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${body}</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
if (!notification) {
|
||||
return (
|
||||
<div className="notif-detail">
|
||||
<div className="notif-empty">Select a notification to read more.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="notif-detail">
|
||||
<div className="notif-detail-header">
|
||||
<div className="notif-detail-meta">
|
||||
{notification.type && (
|
||||
<span className="notif-type-badge" style={getBadgeStyle(notification.color, theme)}>
|
||||
{notification.type}
|
||||
</span>
|
||||
)}
|
||||
<span className="notif-detail-date">{humanizeDate(notification.date)}</span>
|
||||
</div>
|
||||
<div className="notif-detail-title">{notification.title}</div>
|
||||
</div>
|
||||
<iframe
|
||||
key={notification.id}
|
||||
className="notif-detail-body"
|
||||
title="Notification details"
|
||||
sandbox="allow-popups"
|
||||
srcDoc={buildDescriptionDocument(notification.description)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDetail;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { rgba } from 'polished';
|
||||
import { getBadgeStyle } from './NotificationDetail';
|
||||
|
||||
describe('getBadgeStyle', () => {
|
||||
const theme = { colors: { text: { purple: '#8e44ad' } } };
|
||||
|
||||
it('uses a valid hex color for both text and tinted background', () => {
|
||||
const style = getBadgeStyle('#ff0000', theme);
|
||||
expect(style).toEqual({
|
||||
backgroundColor: rgba('#ff0000', 0.15),
|
||||
color: '#ff0000'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts rgb color strings', () => {
|
||||
const style = getBadgeStyle('rgb(0, 128, 255)', theme);
|
||||
expect(style.color).toBe('rgb(0, 128, 255)');
|
||||
expect(style.backgroundColor).toBe(rgba('rgb(0, 128, 255)', 0.15));
|
||||
});
|
||||
|
||||
it('accepts hsl color strings', () => {
|
||||
const style = getBadgeStyle('hsl(210, 100%, 50%)', theme);
|
||||
expect(style.color).toBe('hsl(210, 100%, 50%)');
|
||||
expect(style.backgroundColor).toBe(rgba('hsl(210, 100%, 50%)', 0.15));
|
||||
});
|
||||
|
||||
it('falls back to the theme purple for an unparseable color', () => {
|
||||
const style = getBadgeStyle('not-a-color', theme);
|
||||
expect(style).toEqual({
|
||||
backgroundColor: rgba(theme.colors.text.purple, 0.15),
|
||||
color: theme.colors.text.purple
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the theme purple when color is undefined', () => {
|
||||
const style = getBadgeStyle(undefined, theme);
|
||||
expect(style.color).toBe(theme.colors.text.purple);
|
||||
expect(style.backgroundColor).toBe(rgba(theme.colors.text.purple, 0.15));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import classnames from 'classnames';
|
||||
import { relativeDate } from 'utils/common';
|
||||
|
||||
const NotificationList = ({ items, selectedId, onSelect }) => {
|
||||
return (
|
||||
<ul className="notif-list">
|
||||
{items.map((notification) => {
|
||||
const isActive = selectedId === notification.id;
|
||||
const isUnread = !notification.read;
|
||||
return (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={classnames('notif-list-item', { active: isActive, unread: isUnread })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(notification)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(notification);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classnames('notif-item-title', { unread: isUnread })}>{notification.title}</div>
|
||||
<div className="notif-item-date">{relativeDate(notification.date)}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && <li className="notif-list-empty">No notifications to show.</li>}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationList;
|
||||
@@ -0,0 +1,74 @@
|
||||
import classnames from 'classnames';
|
||||
import { IconDotsVertical } from '@tabler/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { TABS } from '../hooks/useNotifications';
|
||||
|
||||
const menuIcon = (
|
||||
<span className="notif-menu-trigger" aria-label="Notifications menu">
|
||||
<IconDotsVertical size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
);
|
||||
|
||||
const NotificationTabs = ({ activeTab, unreadCount, onTabChange, onMarkAllRead, onClearAll }) => {
|
||||
const dropdownTippyRef = useRef(null);
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const hideDropdown = () => dropdownTippyRef.current?.hide();
|
||||
|
||||
// Clicks inside the detail iframe don't bubble to the parent document, so
|
||||
// tippy's outside-click dismissal never fires. Closing on iframe focus covers it.
|
||||
useEffect(() => {
|
||||
const onWindowBlur = () => {
|
||||
if (document.activeElement?.tagName === 'IFRAME') {
|
||||
hideDropdown();
|
||||
}
|
||||
};
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
return () => window.removeEventListener('blur', onWindowBlur);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="notif-tabs">
|
||||
<div className="notif-tab-group">
|
||||
<button
|
||||
type="button"
|
||||
className={classnames('notif-tab', { active: activeTab === TABS.ALL })}
|
||||
onClick={() => onTabChange(TABS.ALL)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classnames('notif-tab', { active: activeTab === TABS.UNREAD })}
|
||||
onClick={() => onTabChange(TABS.UNREAD)}
|
||||
>
|
||||
Unread
|
||||
{unreadCount > 0 && <span className="notif-tab-badge">{unreadCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
<Dropdown icon={menuIcon} placement="bottom-end" onCreate={onDropdownCreate}>
|
||||
<div
|
||||
className={classnames('dropdown-item', { disabled: unreadCount === 0 })}
|
||||
onClick={() => {
|
||||
if (unreadCount === 0) return;
|
||||
hideDropdown();
|
||||
onMarkAllRead();
|
||||
}}
|
||||
>
|
||||
Mark all as read
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
hideDropdown();
|
||||
onClearAll();
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTabs;
|
||||
@@ -0,0 +1,267 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 800px;
|
||||
height: 520px;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
background-color: ${(props) => props.theme.notifications.bg};
|
||||
|
||||
/* While dragging, stop the detail iframe from swallowing mousemove events,
|
||||
which would otherwise freeze the resize until the cursor re-enters the handle. */
|
||||
&.dragging .notif-detail-body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notif-sidebar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
}
|
||||
|
||||
.notif-resize-handle {
|
||||
flex: 0 0 1px;
|
||||
cursor: col-resize;
|
||||
background: ${(props) => props.theme.notifications.list.borderBottom};
|
||||
position: relative;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
/* widen the hit target without bloating the visible line */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging {
|
||||
background: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
|
||||
.notif-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
}
|
||||
|
||||
.notif-tab-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notif-tab {
|
||||
height: 24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&.active {
|
||||
background-color: ${(props) => props.theme.brand};
|
||||
color: ${(props) => props.theme.background.base};
|
||||
font-weight: 500;
|
||||
|
||||
.notif-tab-badge {
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.brand};
|
||||
border-color: ${(props) => props.theme.background.base};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notif-tab-badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
background-color: ${(props) => rgba(props.theme.brand, 0.1)};
|
||||
color: ${(props) => props.theme.brand};
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notif-menu-trigger {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
}
|
||||
|
||||
.notif-list-empty {
|
||||
padding: 16px 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notif-list-item {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background-color: ${(props) => props.theme.notifications.list.active.bg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notif-item-title {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
&.unread {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.notif-item-date,
|
||||
.notif-detail-date {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notif-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 6px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notif-detail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.notif-detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 1px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.notif-type-badge {
|
||||
height: 24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notif-detail-title {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notif-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notif-empty-text {
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useDragResize } from 'hooks/useDragResize';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import NotificationTabs from './NotificationTabs';
|
||||
import NotificationList from './NotificationList';
|
||||
import NotificationDetail from './NotificationDetail';
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 260;
|
||||
const SIDEBAR_MIN = 200;
|
||||
// Reserved for the detail pane; caps the sidebar at ~420px in the 800px modal.
|
||||
const DETAIL_MIN = 380;
|
||||
|
||||
const NotificationsModal = ({ notifications, onClose }) => {
|
||||
const {
|
||||
visibleNotifications,
|
||||
listed,
|
||||
unreadCount,
|
||||
activeTab,
|
||||
selectedNotification,
|
||||
onTabChange,
|
||||
onSelect,
|
||||
onMarkAllRead,
|
||||
onClearAll
|
||||
} = notifications;
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const [sidebarWidth, setSidebarWidth] = usePersistedState({
|
||||
key: 'notification-sidebar',
|
||||
default: DEFAULT_SIDEBAR_WIDTH
|
||||
});
|
||||
const { dragging, dragWidth, dragbarProps } = useDragResize({
|
||||
containerRef,
|
||||
width: sidebarWidth,
|
||||
onWidthChange: (w) => setSidebarWidth(w ?? DEFAULT_SIDEBAR_WIDTH),
|
||||
minLeft: SIDEBAR_MIN,
|
||||
minRight: DETAIL_MIN
|
||||
});
|
||||
const effectiveWidth = dragging ? dragWidth : sidebarWidth;
|
||||
const isEmpty = visibleNotifications.length === 0;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Notifications"
|
||||
confirmText="Close"
|
||||
handleConfirm={onClose}
|
||||
handleCancel={onClose}
|
||||
hideFooter={true}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
noPadding={true}
|
||||
>
|
||||
<StyledWrapper className={classnames('notifications-modal', { dragging })} ref={containerRef}>
|
||||
<div className="notif-sidebar" style={{ width: effectiveWidth, flexBasis: effectiveWidth }}>
|
||||
<NotificationTabs
|
||||
activeTab={activeTab}
|
||||
unreadCount={unreadCount}
|
||||
onTabChange={onTabChange}
|
||||
onMarkAllRead={onMarkAllRead}
|
||||
onClearAll={onClearAll}
|
||||
/>
|
||||
<NotificationList items={listed} selectedId={selectedNotification?.id} onSelect={onSelect} />
|
||||
</div>
|
||||
<div
|
||||
className={classnames('notif-resize-handle', { dragging })}
|
||||
{...dragbarProps}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
/>
|
||||
{isEmpty ? (
|
||||
<div className="notif-empty">
|
||||
<div className="notif-empty-text">You are all caught up!</div>
|
||||
</div>
|
||||
) : (
|
||||
<NotificationDetail notification={selectedNotification} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsModal;
|
||||
@@ -1,85 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.notifications-modal {
|
||||
margin-inline: -1rem;
|
||||
margin-block: -1.5rem;
|
||||
background-color: ${(props) => props.theme.notifications.bg};
|
||||
}
|
||||
|
||||
.notification-count {
|
||||
display: flex;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: -0.625rem;
|
||||
right: -0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
justify-content: center;
|
||||
font-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) => props.theme.colors.text.yellow};
|
||||
border: solid 2px ${(props) => props.theme.sidebar.bg};
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
|
||||
button.mark-as-read {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
ul.notifications {
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
|
||||
min-height: 400px;
|
||||
height: 100%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
min-width: 150px;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-left: solid 2px transparent;
|
||||
color: ${(props) => props.theme.textLink};
|
||||
border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
background-color: ${(props) => props.theme.notifications.list.active.bg} !important;
|
||||
border-left: solid 2px ${(props) => props.theme.notifications.list.active.border};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.active.hoverBg} !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
.notification-date {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.notification-date {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.pagination {
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,32 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.button`
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
.notification-count {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
color: ${(props) => props.theme.background.base};
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
background-color: ${(props) => props.theme.brand};
|
||||
border: 1.5px solid ${(props) => props.theme.sidebar.bg};
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
clearAllNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead
|
||||
} from 'providers/ReduxStore/slices/notifications';
|
||||
|
||||
export const TABS = { ALL: 'all', UNREAD: 'unread' };
|
||||
|
||||
const useNotifications = () => {
|
||||
const dispatch = useDispatch();
|
||||
const notifications = useSelector((state) => state.notifications.notifications);
|
||||
const clearedIds = useSelector((state) => state.notifications.clearedNotificationIds);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedNotification, setSelectedNotification] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState(TABS.ALL);
|
||||
const [pinnedUnreadIds, setPinnedUnreadIds] = useState(null);
|
||||
|
||||
const visibleNotifications = useMemo(
|
||||
() => notifications.filter((n) => !clearedIds?.includes(n.id)),
|
||||
[notifications, clearedIds]
|
||||
);
|
||||
const unreadCount = visibleNotifications.filter((n) => !n.read).length;
|
||||
// Pin the Unread set on tab entry so reading items doesn't make them vanish.
|
||||
const listed = useMemo(() => {
|
||||
if (activeTab !== TABS.UNREAD) return visibleNotifications;
|
||||
if (!pinnedUnreadIds) return visibleNotifications.filter((n) => !n.read);
|
||||
return visibleNotifications.filter((n) => pinnedUnreadIds.has(n.id));
|
||||
}, [activeTab, visibleNotifications, pinnedUnreadIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (selectedNotification && listed.find((n) => n.id === selectedNotification.id)) return;
|
||||
const first = listed[0];
|
||||
if (!first) {
|
||||
setSelectedNotification(null);
|
||||
return;
|
||||
}
|
||||
setSelectedNotification(first);
|
||||
if (!first.read) {
|
||||
dispatch(markNotificationAsRead({ notificationId: first.id }));
|
||||
}
|
||||
}, [listed, selectedNotification, isOpen]);
|
||||
|
||||
const onTabChange = (tab) => {
|
||||
if (tab === TABS.UNREAD) {
|
||||
const ids = visibleNotifications.filter((n) => !n.read).map((n) => n.id);
|
||||
setPinnedUnreadIds(new Set(ids));
|
||||
} else {
|
||||
setPinnedUnreadIds(null);
|
||||
}
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
const onSelect = (notification) => {
|
||||
setSelectedNotification(notification);
|
||||
if (!notification.read) {
|
||||
dispatch(markNotificationAsRead({ notificationId: notification.id }));
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkAllRead = () => {
|
||||
dispatch(markAllNotificationsAsRead());
|
||||
if (activeTab === TABS.UNREAD) {
|
||||
setPinnedUnreadIds(null);
|
||||
}
|
||||
};
|
||||
const onClearAll = () => dispatch(clearAllNotifications());
|
||||
|
||||
const open = () => {
|
||||
window.ipcRenderer?.send('renderer:notifications-opened');
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
setSelectedNotification(null);
|
||||
setActiveTab(TABS.ALL);
|
||||
setPinnedUnreadIds(null);
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
visibleNotifications,
|
||||
listed,
|
||||
unreadCount,
|
||||
activeTab,
|
||||
selectedNotification,
|
||||
open,
|
||||
close,
|
||||
onTabChange,
|
||||
onSelect,
|
||||
onMarkAllRead,
|
||||
onClearAll
|
||||
};
|
||||
};
|
||||
|
||||
export default useNotifications;
|
||||
@@ -1,214 +1,24 @@
|
||||
import { IconBell } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyleWrapper';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal';
|
||||
import { useEffect } from 'react';
|
||||
import { useApp } from 'providers/App';
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead
|
||||
} from 'providers/ReduxStore/slices/notifications';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { humanizeDate, relativeDate } from 'utils/common';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import NotificationsModal from './NotificationsModal';
|
||||
import useNotifications from './hooks/useNotifications';
|
||||
|
||||
const Notifications = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { version } = useApp();
|
||||
const notifications = useSelector((state) => state.notifications.notifications);
|
||||
|
||||
const [showNotificationsModal, setShowNotificationsModal] = useState(false);
|
||||
const [selectedNotification, setSelectedNotification] = useState(null);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
|
||||
const notificationsStartIndex = (pageNumber - 1) * PAGE_SIZE;
|
||||
const notificationsEndIndex = pageNumber * PAGE_SIZE;
|
||||
const totalPages = Math.ceil(notifications.length / PAGE_SIZE);
|
||||
const unreadNotifications = notifications.filter((notification) => !notification.read);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchNotifications({
|
||||
currentVersion: version
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [showNotificationsModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNotification && notifications?.length > 0 && showNotificationsModal) {
|
||||
let firstNotification = notifications[0];
|
||||
setSelectedNotification(firstNotification);
|
||||
dispatch(markNotificationAsRead({ notificationId: firstNotification?.id }));
|
||||
}
|
||||
}, [notifications, selectedNotification, showNotificationsModal]);
|
||||
|
||||
const reset = () => {
|
||||
setSelectedNotification(null);
|
||||
setPageNumber(1);
|
||||
};
|
||||
|
||||
const handlePrev = (e) => {
|
||||
if (pageNumber - 1 < 1) return;
|
||||
setPageNumber(pageNumber - 1);
|
||||
};
|
||||
|
||||
const handleNext = (e) => {
|
||||
if (pageNumber + 1 > totalPages) return;
|
||||
setPageNumber(pageNumber + 1);
|
||||
};
|
||||
|
||||
const handleNotificationItemClick = (notification) => (e) => {
|
||||
e.preventDefault();
|
||||
setSelectedNotification(notification);
|
||||
dispatch(markNotificationAsRead({ notificationId: notification?.id }));
|
||||
};
|
||||
|
||||
const getSanitizedDescription = (description) => {
|
||||
return DOMPurify.sanitize(encodeURIComponent(description), {
|
||||
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
|
||||
});
|
||||
};
|
||||
|
||||
const modalCustomHeader = (
|
||||
<div className="flex flex-row gap-8">
|
||||
<div className="bruno-modal-header-title">NOTIFICATIONS</div>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<>
|
||||
<div className="normal-case font-normal">
|
||||
{unreadNotifications.length} <span>unread notifications</span>
|
||||
</div>
|
||||
<button
|
||||
className={`select-none ${1 == 2 ? 'opacity-50' : 'text-link mark-as-read cursor-pointer hover:underline'}`}
|
||||
onClick={() => dispatch(markAllNotificationsAsRead())}
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const notifications = useNotifications();
|
||||
const { isOpen, unreadCount, open, close } = notifications;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<a
|
||||
className="relative cursor-pointer"
|
||||
onClick={() => {
|
||||
dispatch(fetchNotifications({
|
||||
currentVersion: version
|
||||
}));
|
||||
setShowNotificationsModal(true);
|
||||
}}
|
||||
aria-label="Check all Notifications"
|
||||
>
|
||||
<>
|
||||
<StyledWrapper onClick={open} aria-label="Check all Notifications">
|
||||
<ToolHint text="Notifications" toolhintId="Notifications" offset={8}>
|
||||
<IconBell
|
||||
size={16}
|
||||
aria-hidden
|
||||
strokeWidth={1.5}
|
||||
className={`${unreadNotifications?.length > 0 ? 'bell' : ''}`}
|
||||
/>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<span className="notification-count text-xs">{unreadNotifications.length}</span>
|
||||
)}
|
||||
<IconBell size={16} aria-hidden strokeWidth={1.5} />
|
||||
{unreadCount > 0 && <span className="notification-count">{unreadCount}</span>}
|
||||
</ToolHint>
|
||||
</a>
|
||||
</StyledWrapper>
|
||||
|
||||
{showNotificationsModal && (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="lg"
|
||||
title="Notifications"
|
||||
confirmText="Close"
|
||||
handleConfirm={() => {
|
||||
setShowNotificationsModal(false);
|
||||
}}
|
||||
handleCancel={() => {
|
||||
setShowNotificationsModal(false);
|
||||
}}
|
||||
hideFooter={true}
|
||||
customHeader={modalCustomHeader}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<div className="notifications-modal">
|
||||
{notifications?.length > 0 ? (
|
||||
<div className="grid grid-cols-4 flex flex-row">
|
||||
<div className="col-span-1 flex flex-col">
|
||||
<ul
|
||||
className="notifications w-full flex flex-col h-[50vh] max-h-[50vh] overflow-y-auto"
|
||||
style={{ maxHeight: '50vh', height: '46vh' }}
|
||||
>
|
||||
{notifications?.slice(notificationsStartIndex, notificationsEndIndex)?.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={`p-4 flex flex-col justify-center ${
|
||||
selectedNotification?.id == notification.id ? 'active' : notification.read ? 'read' : ''
|
||||
}`}
|
||||
onClick={handleNotificationItemClick(notification)}
|
||||
>
|
||||
<div className="notification-title w-full">{notification?.title}</div>
|
||||
<div className="notification-date text-xs py-2">{relativeDate(notification?.date)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="w-full pagination flex flex-row gap-4 justify-center p-2 items-center text-xs">
|
||||
<button
|
||||
className={`pl-2 pr-2 py-3 select-none ${
|
||||
pageNumber <= 1 ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
|
||||
}`}
|
||||
onClick={handlePrev}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<div className="flex flex-row items-center justify-center gap-1">
|
||||
Page
|
||||
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
|
||||
{pageNumber}
|
||||
</div>
|
||||
of
|
||||
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
|
||||
{totalPages}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`pl-2 pr-2 py-3 select-none ${
|
||||
pageNumber == totalPages ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
|
||||
}`}
|
||||
onClick={handleNext}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full col-span-3 p-4 flex-col">
|
||||
<div className="w-full text-lg flex flex-wrap h-fit mb-1">{selectedNotification?.title}</div>
|
||||
<div className="w-full notification-date text-xs mb-4">
|
||||
{humanizeDate(selectedNotification?.date)}
|
||||
</div>
|
||||
<iframe
|
||||
src={`data:text/html,${getSanitizedDescription(selectedNotification?.description)}`}
|
||||
sandbox="allow-popups"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="opacity-50 italic text-xs p-12 flex justify-center">You are all caught up!</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
{isOpen && <NotificationsModal notifications={notifications} onClose={close} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -128,17 +128,6 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconChevronRight,
|
||||
@@ -15,7 +15,7 @@ import Help from 'components/Help';
|
||||
import EndpointVisualDiff from './EndpointVisualDiff';
|
||||
|
||||
// Expandable row - can be used with or without decision buttons
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions, preserveValues = true }) => {
|
||||
const dispatch = useDispatch();
|
||||
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
|
||||
const isExpanded = useSelector((state) => {
|
||||
@@ -25,9 +25,15 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
const [diffData, setDiffData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
if (diffData) return;
|
||||
// Monotonic id so a superseded in-flight fetch (e.g. the user flips the
|
||||
// Preserve toggle mid-request) can't overwrite the latest result.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
// No internal diffData guard: both callers (the expand effect and handleToggle)
|
||||
// already gate on !diffData. Guarding here would capture a stale diffData from
|
||||
// the render that recreated this callback and silently skip the toggle re-fetch.
|
||||
const requestId = ++requestIdRef.current;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -36,20 +42,45 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
|
||||
collectionPath,
|
||||
endpointId: endpoint.id,
|
||||
newSpec
|
||||
newSpec,
|
||||
preserveValues
|
||||
});
|
||||
|
||||
if (requestId !== requestIdRef.current) return; // superseded by a newer fetch
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setDiffData(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(formatIpcError(err) || 'Failed to load diff data');
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec, preserveValues]);
|
||||
|
||||
// Re-fetch the preview when the preserve toggle changes — the EXPECTED column
|
||||
// depends on it. Expanded rows re-fetch in place (the old diff stays visible
|
||||
// and swaps when the new one arrives, so the row never blanks). Collapsed rows
|
||||
// just drop their cache so the next expand fetches fresh — invisible to the user.
|
||||
const didMountPreserve = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountPreserve.current) {
|
||||
didMountPreserve.current = true;
|
||||
return;
|
||||
}
|
||||
if (isExpanded) {
|
||||
loadDiffData(); // bumps requestId, keeps old diff until the new one lands
|
||||
} else {
|
||||
requestIdRef.current++; // invalidate any in-flight fetch
|
||||
setDiffData(null);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec]);
|
||||
// Intentionally only re-run when the toggle flips — not on isExpanded/loadDiffData
|
||||
// changes, which the dedicated load effect + handleToggle already cover.
|
||||
}, [preserveValues]);
|
||||
|
||||
// Load diff data when expanded (e.g. restored from Redux state)
|
||||
useEffect(() => {
|
||||
@@ -126,18 +157,21 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
|
||||
{isExpanded && (
|
||||
<div className="review-row-diff">
|
||||
{isLoading && (
|
||||
{/* Spinner only on the initial load. A re-fetch (e.g. toggling Preserve)
|
||||
keeps the previous diff visible and swaps it in place, so the row
|
||||
never blanks/flickers. */}
|
||||
{isLoading && !diffData && !error && (
|
||||
<div className="diff-loading">
|
||||
<IconLoader2 size={16} className="spinning" />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
{error && !diffData && (
|
||||
<div className="diff-error">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
{diffData && !isLoading && !error && (
|
||||
{diffData && !error && (
|
||||
<EndpointVisualDiff
|
||||
oldData={diffData.oldData}
|
||||
newData={diffData.newData}
|
||||
|
||||
@@ -687,7 +687,7 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.colors.text.green};
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
@@ -724,9 +724,9 @@ const StyledWrapper = styled.div`
|
||||
transition: all 0.15s;
|
||||
|
||||
&.active {
|
||||
border-color: ${(props) => props.theme.button2.color.primary.border};
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
color: ${(props) => props.theme.button2.color.primary.text};
|
||||
border-color: ${(props) => props.theme.accents.primary};
|
||||
background: ${(props) => rgba(props.theme.accents.primary, 0.07)};
|
||||
color: ${(props) => props.theme.accents.primary};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1770,6 +1770,7 @@ const StyledWrapper = styled.div`
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
user-select: none; /* these are controls, not selectable text (e.g. double-click on the info icon) */
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
@@ -1804,6 +1805,77 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
/* the three Preserve elements read as one control: label + info + toggle */
|
||||
.preserve-values-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.preserve-values-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the shared InfoCircle icon ships a hardcoded ml-2 (8px); override it
|
||||
so the info icon sits tight to the label. It is a hover-only tooltip
|
||||
affordance, not a button — use a help cursor and never show a
|
||||
click/focus box around it. */
|
||||
svg {
|
||||
margin-left: 4px;
|
||||
cursor: help;
|
||||
}
|
||||
svg:focus,
|
||||
svg:focus-visible,
|
||||
span:focus,
|
||||
span:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* compact themed track + knob toggle, sized to the button row height */
|
||||
.preserve-toggle {
|
||||
margin-right: 4px; /* space between the toggle and the label */
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.button2.color.primary.bg};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.preserve-toggle-knob {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: left 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.active .preserve-toggle-knob {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sync-review-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -88,6 +88,7 @@ const SyncReviewPage = ({
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
|
||||
const [preserveValues, setPreserveValues] = useState(true);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
|
||||
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
|
||||
@@ -210,7 +211,8 @@ const SyncReviewPage = ({
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
specUpdates: filteredSpecChanges,
|
||||
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming'),
|
||||
preserveValues
|
||||
});
|
||||
};
|
||||
|
||||
@@ -238,6 +240,21 @@ const SyncReviewPage = ({
|
||||
</div>
|
||||
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
|
||||
<div className="bulk-actions">
|
||||
<div className="preserve-values-control">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-pressed={preserveValues}
|
||||
className={`preserve-toggle ${preserveValues ? 'active' : ''}`}
|
||||
onClick={() => setPreserveValues((v) => !v)}
|
||||
>
|
||||
<span className="preserve-toggle-knob" />
|
||||
</button>
|
||||
<span className="preserve-values-label">Preserve values</span>
|
||||
<Help icon="info" size={12} placement="top" width={260}>
|
||||
When enabled, your edited values are preserved during sync. When disabled, all values are updated to match the OpenAPI spec.
|
||||
</Help>
|
||||
</div>
|
||||
{specDrift?.unifiedDiff && (
|
||||
<button
|
||||
className="bulk-btn"
|
||||
@@ -329,6 +346,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -353,6 +371,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -377,6 +396,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ const useSyncFlow = ({
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync', preserveValues = true) => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
@@ -71,7 +71,8 @@ const useSyncFlow = ({
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
mode,
|
||||
endpointDecisions: decisions
|
||||
endpointDecisions: decisions,
|
||||
preserveValues
|
||||
});
|
||||
|
||||
setPendingSyncMode(null);
|
||||
@@ -102,7 +103,7 @@ const useSyncFlow = ({
|
||||
const handleApplySync = (selections) => {
|
||||
const mode = pendingSyncMode || 'sync';
|
||||
setPendingSyncMode(null);
|
||||
performSync(selections, mode);
|
||||
performSync(selections, mode, selections?.preserveValues ?? true);
|
||||
};
|
||||
|
||||
const cancelConfirmModal = () => {
|
||||
|
||||
@@ -131,16 +131,6 @@ const OpenAPISyncTab = ({ collection }) => {
|
||||
error={error}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBolt,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLoader2,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai';
|
||||
|
||||
const OpenAiLogo = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AnthropicLogo = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M17.304 3.541h-3.672l6.696 16.918h3.672l-6.696-16.918Zm-10.608 0L0 20.459h3.744l1.368-3.584h6.624l1.368 3.584h3.744L10.152 3.54H6.696Zm.432 10.418 2.208-5.784 2.208 5.784H7.128Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PROVIDER_LOGOS = {
|
||||
openai: OpenAiLogo,
|
||||
anthropic: AnthropicLogo
|
||||
};
|
||||
|
||||
const stopBubble = (e) => e.stopPropagation();
|
||||
|
||||
const ProviderCard = ({
|
||||
provider,
|
||||
providerEnabled,
|
||||
providerToggle,
|
||||
models,
|
||||
isModelEnabled,
|
||||
onToggleModel,
|
||||
onStatusChange
|
||||
}) => {
|
||||
const Logo = PROVIDER_LOGOS[provider.id];
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [keyDraft, setKeyDraft] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
|
||||
const prev = useRef({ enabled: providerEnabled });
|
||||
useEffect(() => {
|
||||
const was = prev.current;
|
||||
if (!was.enabled && providerEnabled) {
|
||||
setExpanded(true);
|
||||
} else if (was.enabled && !providerEnabled) {
|
||||
setExpanded(false);
|
||||
}
|
||||
prev.current = { enabled: providerEnabled };
|
||||
}, [providerEnabled]);
|
||||
|
||||
const isEditing = editing || !provider.configured;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = keyDraft.trim();
|
||||
if (!trimmed) return;
|
||||
setSaving(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed });
|
||||
onStatusChange?.(status);
|
||||
setKeyDraft('');
|
||||
setShowKey(false);
|
||||
setEditing(false);
|
||||
setFeedback({ type: 'success', message: 'API key saved' });
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'error', message: err.message || 'Failed to save API key' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setFeedback(null);
|
||||
try {
|
||||
const status = await clearAiApiKey({ providerId: provider.id });
|
||||
onStatusChange?.(status);
|
||||
setEditing(false);
|
||||
setKeyDraft('');
|
||||
toast.success(`${provider.label} API key removed`);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to clear API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const result = await testAiProvider({ providerId: provider.id });
|
||||
if (result.ok) {
|
||||
setFeedback({ type: 'success', message: 'Connection successful' });
|
||||
} else {
|
||||
setFeedback({ type: 'error', message: result.error || 'Connection failed' });
|
||||
}
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'error', message: err.message || 'Connection failed' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditing(false);
|
||||
setKeyDraft('');
|
||||
setShowKey(false);
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
const handleStartEdit = async () => {
|
||||
setEditing(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const current = await getAiApiKey({ providerId: provider.id });
|
||||
setKeyDraft(current || '');
|
||||
} catch (err) {
|
||||
// If we can't fetch it (decrypt failure etc.), leave the field empty.
|
||||
setKeyDraft('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (keyDraft.trim() && !saving) handleSave();
|
||||
} else if (e.key === 'Escape' && provider.configured) {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length;
|
||||
|
||||
return (
|
||||
<div className={`provider-row ${expanded ? 'expanded' : ''}`} data-testid={`ai-provider-${provider.id}`}>
|
||||
<div
|
||||
className="provider-header flex items-center justify-between gap-3 px-3 py-2.5 cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{Logo ? <Logo className="provider-logo w-[18px] h-[18px] flex-shrink-0" /> : null}
|
||||
<span className="font-semibold text-[12.5px]">{provider.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 flex-shrink-0">
|
||||
<span className={`provider-status inline-flex items-center gap-1.5 text-[11px] ${provider.configured ? 'configured' : ''}`}>
|
||||
<span className={`status-dot w-[7px] h-[7px] rounded-full ${provider.configured ? 'configured' : ''}`} />
|
||||
{provider.configured
|
||||
? `${enabledModelsCount}/${models.length} models`
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
<span className="flex items-center" onClick={stopBubble}>
|
||||
{providerToggle}
|
||||
</span>
|
||||
<span className={`chevron flex items-center ${expanded ? 'expanded' : ''}`}>
|
||||
<IconChevronDown size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`provider-body-wrapper ${expanded ? 'open' : ''}`}>
|
||||
<div className="provider-body-inner">
|
||||
<div className="provider-body flex flex-col gap-3.5 px-3 pt-3 pb-3">
|
||||
{/* API key */}
|
||||
<div>
|
||||
<div className="key-section-label flex items-center justify-between gap-2 text-[11px] mb-1">
|
||||
<span>API Key</span>
|
||||
</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<div
|
||||
className="key-display-row flex items-center justify-between gap-2 h-8 box-border pl-2.5 pr-0.5"
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<span className="key-display-mask text-xs">••••••••••••••••</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !providerEnabled}
|
||||
title="Test connection"
|
||||
aria-label="Test connection"
|
||||
>
|
||||
{testing ? <IconLoader2 size={15} className="spin" /> : <IconBolt size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleStartEdit}
|
||||
title="Replace key"
|
||||
aria-label="Replace key"
|
||||
>
|
||||
<IconPencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleClear}
|
||||
title="Remove key"
|
||||
aria-label="Remove key"
|
||||
>
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5" onClick={stopBubble}>
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<input
|
||||
id={`api-key-${provider.id}`}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-8"
|
||||
placeholder={provider.apiKeyPlaceholder}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={stopBubble}
|
||||
autoFocus
|
||||
data-testid={`ai-provider-${provider.id}-key-input`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="key-eye-btn absolute right-1 p-1 inline-flex items-center cursor-pointer"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showKey ? <IconEyeOff size={14} /> : <IconEye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
|
||||
disabled={saving || !keyDraft.trim()}
|
||||
onClick={handleSave}
|
||||
data-testid={`ai-provider-${provider.id}-save`}
|
||||
>
|
||||
{saving ? <IconLoader2 size={13} className="spin" /> : <IconCheck size={13} />}
|
||||
Save
|
||||
</button>
|
||||
{provider.configured && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={`feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5 ${feedback.type}`}
|
||||
role="status"
|
||||
>
|
||||
{feedback.type === 'success' ? <IconCheck size={12} /> : <IconAlertCircle size={12} />}
|
||||
{feedback.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{models.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="models-label-row flex items-center justify-between text-[11px]">
|
||||
<span>Models</span>
|
||||
{!provider.configured && (
|
||||
<span className="keyless-hint flex items-center gap-1.5 text-[11px] py-1">
|
||||
<IconAlertCircle size={12} />
|
||||
Add an API key to enable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{models.map((model) => {
|
||||
const enabled = isModelEnabled(model.id);
|
||||
const disabled = !provider.configured || !providerEnabled;
|
||||
return (
|
||||
<label
|
||||
key={model.id}
|
||||
className={`model-chip flex items-center gap-2 px-2.5 py-1.5 cursor-pointer select-none ${enabled && !disabled ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer m-0"
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggleModel(model.id, !enabled)}
|
||||
/>
|
||||
<span className="text-xs">{model.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderCard;
|
||||
@@ -0,0 +1,243 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.ai-master {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.ai-master-icon {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
|
||||
.ai-master-summary {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.ai-section-header {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.ai-empty-notice {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: 1px dashed ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&.expanded {
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.accent}08;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.provider-status {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.configured {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
background: ${(props) => props.theme.input.border};
|
||||
|
||||
&.configured {
|
||||
background: ${(props) => props.theme.colors.text.green};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.green}25;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth expand/collapse using grid-template-rows trick */
|
||||
.provider-body-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.2s ease;
|
||||
|
||||
&.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-body-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.provider-body {
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.key-section-label {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.key-input {
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.key-eye-btn {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
}
|
||||
}
|
||||
|
||||
.key-display-row {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.key-display-mask {
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
color: white;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.danger:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
|
||||
&.success {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
background: ${(props) => props.theme.colors.text.green}10;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
}
|
||||
|
||||
.models-label-row {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.model-chip {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: ${(props) => props.theme.colors.accent}08;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.colors.accent}06;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
input,
|
||||
label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keyless-hint {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useFormik } from 'formik';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconStars } from '@tabler/icons';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import { getAiStatus } from 'utils/ai';
|
||||
import ProviderCard from './ProviderCard';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const aiPreferencesSchema = Yup.object().shape({
|
||||
enabled: Yup.boolean(),
|
||||
providers: Yup.object(),
|
||||
models: Yup.object(),
|
||||
defaultModel: Yup.string().max(200).nullable()
|
||||
});
|
||||
|
||||
const AI = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [statusError, setStatusError] = useState(null);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const next = await getAiStatus();
|
||||
setStatus(next);
|
||||
setStatusError(null);
|
||||
} catch (err) {
|
||||
setStatusError(err.message || 'Failed to load AI status');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
const providerIds = status ? Object.keys(status.providers) : [];
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
enabled: get(preferences, 'ai.enabled', false),
|
||||
providers: providerIds.reduce((acc, id) => {
|
||||
acc[id] = { enabled: get(preferences, `ai.providers.${id}.enabled`, false) };
|
||||
return acc;
|
||||
}, {}),
|
||||
models: get(preferences, 'ai.models', {}),
|
||||
defaultModel: get(preferences, 'ai.defaultModel', '')
|
||||
},
|
||||
validationSchema: aiPreferencesSchema,
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(
|
||||
(values) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
ai: {
|
||||
enabled: values.enabled,
|
||||
providers: values.providers,
|
||||
models: values.models,
|
||||
defaultModel: values.defaultModel || ''
|
||||
}
|
||||
})
|
||||
).catch((err) => {
|
||||
console.error('Failed to save AI preferences:', err);
|
||||
toast.error('Failed to save AI preferences');
|
||||
});
|
||||
},
|
||||
[dispatch, preferences]
|
||||
);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
aiPreferencesSchema
|
||||
.validate(values, { abortEarly: true })
|
||||
.then((validated) => handleSaveRef.current(validated))
|
||||
.catch(() => {});
|
||||
}, 400),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
useEffect(() => () => debouncedSave.flush(), [debouncedSave]);
|
||||
|
||||
const modelsByProvider = useMemo(() => {
|
||||
const grouped = {};
|
||||
(status?.models || []).forEach((model) => {
|
||||
if (!grouped[model.provider]) grouped[model.provider] = [];
|
||||
grouped[model.provider].push(model);
|
||||
});
|
||||
return grouped;
|
||||
}, [status]);
|
||||
|
||||
const isModelEnabled = (modelId) => get(formik.values, `models.${modelId}.enabled`, true);
|
||||
|
||||
const handleToggleModel = (modelId, next) => {
|
||||
formik.setFieldValue(`models.${modelId}.enabled`, next);
|
||||
};
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!status || !formik.values.enabled) return 'Turn on to configure providers and models';
|
||||
const usableProviders = Object.values(status.providers).filter(
|
||||
(p) => p.configured && formik.values.providers?.[p.id]?.enabled
|
||||
);
|
||||
if (usableProviders.length === 0) return 'Add a provider to get started';
|
||||
// Count models live from formik + current key status, not the electron-side
|
||||
// snapshot which lags behind toggle changes during the save debounce window.
|
||||
const totalEnabledModels = (status.models || []).filter((m) => {
|
||||
if (!formik.values.providers?.[m.provider]?.enabled) return false;
|
||||
if (!status.providers?.[m.provider]?.configured) return false;
|
||||
return isModelEnabled(m.id);
|
||||
}).length;
|
||||
const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
|
||||
return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`;
|
||||
}, [status, formik.values.enabled, formik.values.providers, formik.values.models]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
|
||||
<div className="section-header">AI</div>
|
||||
|
||||
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold">
|
||||
<IconStars size={15} strokeWidth={1.75} className="ai-master-icon" />
|
||||
<span>AI Features</span>
|
||||
</div>
|
||||
<span className="ai-master-summary text-[11px]">{summary}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
size="m"
|
||||
isOn={formik.values.enabled}
|
||||
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs" role="alert">
|
||||
{statusError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formik.values.enabled && !statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs">
|
||||
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formik.values.enabled && status && (
|
||||
<>
|
||||
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mt-[18px] mb-2">
|
||||
Providers
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{providerIds.map((id) => {
|
||||
const provider = status.providers[id];
|
||||
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
|
||||
|
||||
const providerToggle = (
|
||||
<ToggleSwitch
|
||||
size="s"
|
||||
isOn={providerEnabled}
|
||||
handleToggle={() =>
|
||||
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
key={id}
|
||||
provider={provider}
|
||||
providerEnabled={providerEnabled}
|
||||
providerToggle={providerToggle}
|
||||
models={modelsByProvider[id] || []}
|
||||
isModelEnabled={isModelEnabled}
|
||||
onToggleModel={handleToggleModel}
|
||||
onStatusChange={(next) => setStatus(next)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AI;
|
||||
@@ -6,20 +6,38 @@ import StyledWrapper from './StyledWrapper';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconFlask } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
|
||||
// Commented out while there are no active beta features. Re-enable this import when
|
||||
// adding a beta feature its keys are then referenced as BETA_FEATURE_IDS.MY_FEATURE in the BETA_FEATURES array.
|
||||
// import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
|
||||
|
||||
/**
|
||||
* UI metadata for beta features rendered in Preferences.
|
||||
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
|
||||
* UI metadata for the Beta Features section in Preferences — one entry per toggle.
|
||||
* The whole tab is data-driven from this array: the form fields, validation schema,
|
||||
* initial values and the rendered checkboxes are all generated from it.
|
||||
*
|
||||
* Each entry has the shape { id, label, description }:
|
||||
* - id (required) the feature key. MUST be a value from BETA_FEATURES in
|
||||
* utils/beta-features.js (imported here as BETA_FEATURE_IDS). It is
|
||||
* used as the preference key (preferences.beta[id]), the form field
|
||||
* name and the checkbox id, so it must be stable and unique.
|
||||
* - label (required) short name shown next to the checkbox.
|
||||
* - description (required) one-line explanation shown under the label.
|
||||
*
|
||||
* To add a beta feature:
|
||||
* 1. Add its key to BETA_FEATURES in utils/beta-features.js (e.g. MY_FEATURE: 'my-feature').
|
||||
* 2. Add an entry to the array below using BETA_FEATURE_IDS.MY_FEATURE.
|
||||
* 3. Gate the feature in code with useBetaFeature(BETA_FEATURES.MY_FEATURE).
|
||||
*
|
||||
* When the array is empty, the Beta tab shows "No beta features are currently available",
|
||||
* so a feature can be hidden by simply removing or commenting out its entry.
|
||||
*/
|
||||
const BETA_FEATURES = [
|
||||
{
|
||||
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
|
||||
label: 'OpenAPI Sync',
|
||||
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
|
||||
}
|
||||
// {
|
||||
// id: BETA_FEATURE_IDS.OPENAPI_SYNC,
|
||||
// label: 'OpenAPI Sync',
|
||||
// description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
|
||||
// }
|
||||
];
|
||||
|
||||
const Beta = ({ close }) => {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, refreshPacCache } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { IconEye, IconEyeOff, IconRefresh } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import SystemProxy from './SystemProxy';
|
||||
|
||||
@@ -103,6 +103,12 @@ const ProxySettings = ({ close }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRefreshPac = () => {
|
||||
dispatch(refreshPacCache())
|
||||
.then(() => toast.success('PAC cache refreshed'))
|
||||
.catch(() => toast.error('Failed to refresh PAC cache'));
|
||||
};
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState(() => {
|
||||
if (preferences.proxy.disabled) return 'off';
|
||||
@@ -451,6 +457,15 @@ const ProxySettings = ({ close }) => {
|
||||
? 'Enter the URL to your PAC file'
|
||||
: 'Supports .pac files for automatic proxy configuration'}
|
||||
</p>
|
||||
{formik.values.pac.source ? (
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline flex flex-row items-center w-fit mt-2"
|
||||
onClick={handleRefreshPac}
|
||||
>
|
||||
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
|
||||
Refetch
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
IconKeyboard,
|
||||
IconZoomQuestion,
|
||||
IconSquareLetterB,
|
||||
IconDatabase
|
||||
IconDatabase,
|
||||
IconStars
|
||||
} from '@tabler/icons';
|
||||
|
||||
import Support from './Support';
|
||||
@@ -20,6 +21,7 @@ import Proxy from './ProxySettings';
|
||||
import Display from './Display';
|
||||
import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
import AI from './AI';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Cache from './Cache/index';
|
||||
@@ -64,6 +66,10 @@ const Preferences = () => {
|
||||
return <Beta />;
|
||||
}
|
||||
|
||||
case 'ai': {
|
||||
return <AI />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
@@ -98,6 +104,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('ai')} role="tab" onClick={() => setTab('ai')}>
|
||||
<IconStars size={16} strokeWidth={1.5} />
|
||||
AI
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
|
||||
@@ -81,14 +81,15 @@ const AuthMode = ({ item, collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
showTickMark={true}
|
||||
data-testid="auth-mode-dropdown"
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -295,7 +295,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
{
|
||||
tokenPlacement === 'header'
|
||||
? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -311,7 +311,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">Query Param Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -185,7 +185,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
{
|
||||
tokenPlacement === 'header'
|
||||
? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -201,7 +201,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">Query Param Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -80,6 +80,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
|
||||
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
|
||||
]}
|
||||
data-testid="grant-type-dropdown"
|
||||
selectedItemId={oAuth?.grantType}
|
||||
placement="bottom-end"
|
||||
>
|
||||
|
||||
@@ -229,7 +229,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
</div>
|
||||
|
||||
{tokenPlacement == 'header' ? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-header-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-header-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -245,7 +245,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">URL Query Key</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -189,7 +189,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
{
|
||||
tokenPlacement === 'header'
|
||||
? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -205,7 +205,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">Query Param Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import AwsV4Auth from './AwsV4Auth';
|
||||
import BearerAuth from './BearerAuth';
|
||||
@@ -15,22 +15,11 @@ import ApiKeyAuth from './ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import OAuth2 from './OAuth2/index';
|
||||
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
|
||||
|
||||
const getTreePathFromCollectionToItem = (collection, _item) => {
|
||||
let path = [];
|
||||
let item = findItemInCollection(collection, _item?.uid);
|
||||
while (item) {
|
||||
path.unshift(item);
|
||||
item = findParentItemInCollection(collection, item?.uid);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
|
||||
const Auth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
// Create a request object to pass to the auth components
|
||||
const request = item.draft
|
||||
@@ -42,34 +31,10 @@ const Auth = ({ item, collection }) => {
|
||||
return dispatch(saveRequest(item.uid, collection.uid));
|
||||
};
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
@@ -104,12 +69,11 @@ const Auth = ({ item, collection }) => {
|
||||
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,12 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
|
||||
import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import AuthMode from '../Auth/AuthMode/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'query', label: 'Query' },
|
||||
@@ -172,7 +174,20 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({
|
||||
key,
|
||||
label,
|
||||
indicator: key === 'auth' && hasAuth ? <StatusDot dataTestId="auth" /> : null
|
||||
})),
|
||||
[hasAuth]
|
||||
);
|
||||
|
||||
const handlePrettify = useCallback(() => {
|
||||
if (queryEditorRef.current?.beautifyRequestBody) {
|
||||
|
||||
@@ -39,7 +39,7 @@ const MessageToolbar = ({
|
||||
</ToolHint>
|
||||
|
||||
<ToolHint text="Generate sample" toolhintId={`regenerate-msg-${index}`}>
|
||||
<button onClick={onRegenerateMessage} className="toolbar-btn">
|
||||
<button onClick={onRegenerateMessage} className="toolbar-btn" data-testid={`grpc-regenerate-message-${index}`}>
|
||||
<IconRefresh size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import GrpcAuthMode from './GrpcAuthMode';
|
||||
@@ -9,32 +9,32 @@ import OAuth2 from '../../Auth/OAuth2/index';
|
||||
import WsseAuth from '../../Auth/WsseAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
// List of auth modes supported by gRPC
|
||||
// Note: Only header-based auth modes work with gRPC
|
||||
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
|
||||
// and cannot be supported in gRPC requests as of now
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
|
||||
import { AUTH_MODES_GRPC } from 'utils/common/constants';
|
||||
|
||||
const GrpcAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
const request = item.draft
|
||||
? get(item, 'draft.request', {})
|
||||
: get(item, 'request', {});
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
const save = () => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
};
|
||||
|
||||
// Reset to 'none' if current auth mode is not supported by gRPC
|
||||
useEffect(() => {
|
||||
if (authMode && !supportedGrpcAuthModes.includes(authMode)) {
|
||||
if (authMode && !AUTH_MODES_GRPC.includes(authMode)) {
|
||||
dispatch(
|
||||
updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
@@ -45,35 +45,6 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
}
|
||||
}, [authMode, collection.uid, dispatch, item.uid]);
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'none': {
|
||||
@@ -95,15 +66,13 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
// Only show inherited auth if it's one of the supported types
|
||||
if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
|
||||
if (inheritedSource && AUTH_MODES_GRPC.includes(inheritedSource.auth?.mode)) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,8 @@ import Documentation from 'components/Documentation/index';
|
||||
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import { AUTH_MODES_GRPC } from 'utils/common/constants';
|
||||
|
||||
const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -53,8 +55,11 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
const body = getPropertyFromDraftOrRequest(item, 'request.body');
|
||||
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
|
||||
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
|
||||
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item, AUTH_MODES_GRPC),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
const grpcMessagesCount = body?.grpc?.length || 0;
|
||||
|
||||
@@ -88,7 +93,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
{
|
||||
key: 'auth',
|
||||
label: 'Auth',
|
||||
indicator: auth?.mode && auth.mode !== 'none' ? <StatusDot type="default" /> : null
|
||||
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
@@ -96,7 +101,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
|
||||
}
|
||||
];
|
||||
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, auth?.mode, docs]);
|
||||
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, hasAuth, docs]);
|
||||
|
||||
// Initialize tab to 'body' if no tab is currently set
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import StatusDot from 'components/StatusDot';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import AuthMode from '../Auth/AuthMode/index';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'params', label: 'Params' },
|
||||
@@ -54,7 +55,6 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
const getProperty = useCallback(
|
||||
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
|
||||
[item.draft, item]
|
||||
@@ -86,6 +86,12 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
|
||||
const indicators = useMemo(() => {
|
||||
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
|
||||
const hasTestError = item.testScriptErrorMessage;
|
||||
@@ -94,7 +100,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
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,
|
||||
auth: hasAuth ? <StatusDot dataTestId="auth" /> : 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,
|
||||
@@ -102,7 +108,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
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]);
|
||||
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -10,6 +12,7 @@ import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Script = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -55,6 +58,20 @@ const Script = ({ item, collection }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: item.uid,
|
||||
editorRef: preRequestEditorRef,
|
||||
scriptPhase: 'pre-request',
|
||||
isVisible: activeTab === 'pre-request'
|
||||
});
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: item.uid,
|
||||
editorRef: postResponseEditorRef,
|
||||
scriptPhase: 'post-response',
|
||||
isVisible: activeTab === 'post-response'
|
||||
});
|
||||
|
||||
const onRequestScriptEdit = (value) => {
|
||||
dispatch(
|
||||
updateRequestScript({
|
||||
@@ -78,6 +95,8 @@ const Script = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
|
||||
|
||||
@@ -104,41 +123,57 @@ const Script = ({ item, collection }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
requestContext={requestContext}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
requestContext={requestContext}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Tests = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -29,8 +32,16 @@ const Tests = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: item.uid,
|
||||
editorRef: testsEditorRef,
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
|
||||
return (
|
||||
<div data-testid="test-script-editor">
|
||||
<div data-testid="test-script-editor" className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
@@ -47,6 +58,7 @@ const Tests = ({ item, collection }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -72,17 +74,33 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -97,6 +115,7 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="request-vars"
|
||||
testId={`request-vars-${varType === 'response' ? 'res' : 'req'}`}
|
||||
columns={columns}
|
||||
rows={vars || []}
|
||||
onChange={handleVarsChange}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import BearerAuth from '../../Auth/BearerAuth';
|
||||
@@ -6,16 +6,15 @@ import BasicAuth from '../../Auth/BasicAuth';
|
||||
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const supportedAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
|
||||
import { AUTH_MODES_WS } from 'utils/common/constants';
|
||||
|
||||
const WSAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
const request = item.draft
|
||||
? get(item, 'draft.request', {})
|
||||
@@ -25,9 +24,14 @@ const WSAuth = ({ item, collection }) => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
};
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
// Reset to 'none' if current auth mode is not supported
|
||||
useEffect(() => {
|
||||
if (authMode && !supportedAuthModes.includes(authMode)) {
|
||||
if (authMode && !AUTH_MODES_WS.includes(authMode)) {
|
||||
dispatch(updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
@@ -36,35 +40,6 @@ const WSAuth = ({ item, collection }) => {
|
||||
}
|
||||
}, [authMode, collection.uid, dispatch, item.uid]);
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'none': {
|
||||
@@ -91,26 +66,24 @@ const WSAuth = ({ item, collection }) => {
|
||||
);
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
// Check if inherited auth is OAuth1/OAuth2 - not supported for WebSockets
|
||||
if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') {
|
||||
if (inheritedSource?.auth?.mode === 'oauth1' || inheritedSource?.auth?.mode === 'oauth2') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
{source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
{inheritedSource.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show inherited auth if it's one of the supported types
|
||||
if (source && supportedAuthModes.includes(source.auth?.mode)) {
|
||||
if (inheritedSource && AUTH_MODES_WS.includes(inheritedSource.auth?.mode)) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div> Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div> Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,26 @@ import React, { useMemo, useCallback, useRef } from 'react';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import RequestHeaders from 'components/RequestPane/RequestHeaders';
|
||||
import StatusDot from 'components/StatusDot/index';
|
||||
import { find } from 'lodash';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { IconPlus, IconWand } from '@tabler/icons';
|
||||
import { find, get } from 'lodash';
|
||||
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
|
||||
import { prettifyJsonString, uuid } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import toast from 'react-hot-toast';
|
||||
import WsBody from '../WsBody/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import WSAuth from './WSAuth';
|
||||
import WSAuthMode from './WSAuth/WSAuthMode';
|
||||
import WSSettingsPane from '../WSSettingsPane/index';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import { AUTH_MODES_WS } from 'utils/common/constants';
|
||||
|
||||
const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -24,6 +33,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tab) => {
|
||||
dispatch(updateRequestPaneTab({
|
||||
@@ -34,10 +45,70 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const addNewMessage = useCallback(() => {
|
||||
const currentMessages = Array.isArray(body?.ws)
|
||||
? body.ws.map((msg) => ({ ...msg, selected: false }))
|
||||
: [];
|
||||
currentMessages.push({
|
||||
uid: uuid(),
|
||||
name: `message ${currentMessages.length + 1}`,
|
||||
content: '{}',
|
||||
type: 'json',
|
||||
selected: true
|
||||
});
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
const onPrettifyAll = useCallback(() => {
|
||||
const currentMessages = [...(body?.ws || [])];
|
||||
let changed = false;
|
||||
|
||||
currentMessages.forEach((msg, i) => {
|
||||
if (msg.type === 'json') {
|
||||
try {
|
||||
const pretty = prettifyJsonString(msg.content);
|
||||
if (pretty !== msg.content) {
|
||||
currentMessages[i] = { ...msg, content: pretty };
|
||||
changed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip invalid json
|
||||
}
|
||||
} else if (msg.type === 'xml') {
|
||||
try {
|
||||
const pretty = xmlFormat(msg.content, { collapseContent: true });
|
||||
if (pretty !== msg.content) {
|
||||
currentMessages[i] = { ...msg, content: pretty };
|
||||
changed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip invalid xml
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} else {
|
||||
toast.error('Nothing to prettify');
|
||||
}
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
|
||||
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
|
||||
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item, AUTH_MODES_WS),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const allTabs = useMemo(() => {
|
||||
@@ -55,7 +126,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
{
|
||||
key: 'auth',
|
||||
label: 'Auth',
|
||||
indicator: auth.mode !== 'none' ? <StatusDot type="default" /> : null
|
||||
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
@@ -68,7 +139,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
|
||||
}
|
||||
];
|
||||
}, [activeHeadersLength, auth.mode, docs]);
|
||||
}, [activeHeadersLength, hasAuth, docs]);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
switch (requestPaneTab) {
|
||||
@@ -77,9 +148,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
<WsBody
|
||||
item={item}
|
||||
collection={collection}
|
||||
hideModeSelector={true}
|
||||
hidePrettifyButton={true}
|
||||
handleRun={handleRun}
|
||||
onAddMessage={addNewMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -99,17 +169,41 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}
|
||||
}, [requestPaneTab, item, collection, handleRun]);
|
||||
}, [requestPaneTab, item, collection, handleRun, addNewMessage]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
}
|
||||
|
||||
const rightContent = requestPaneTab === 'auth' ? (
|
||||
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
|
||||
<WSAuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : null;
|
||||
let rightContent = null;
|
||||
if (requestPaneTab === 'auth') {
|
||||
rightContent = (
|
||||
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
|
||||
<WSAuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
} else if (requestPaneTab === 'body') {
|
||||
rightContent = (
|
||||
<div ref={rightContentRef} className="flex items-center gap-2">
|
||||
<ToolHint text="Prettify All" toolhintId="prettify-all-ws">
|
||||
<ActionIcon
|
||||
data-testid="ws-prettify-all"
|
||||
onClick={onPrettifyAll}
|
||||
>
|
||||
<IconWand size={14} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
<ToolHint text="Add Message" toolhintId="add-msg-ws">
|
||||
<ActionIcon
|
||||
data-testid="ws-add-message"
|
||||
onClick={addNewMessage}
|
||||
>
|
||||
<IconPlus size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
|
||||
@@ -1,72 +1,108 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border0};
|
||||
|
||||
&.single {
|
||||
height: 100%;
|
||||
/* Dim the row content when disabled, but not the tooltip */
|
||||
.accordion-left > :not(.toolhint),
|
||||
.accordion-actions,
|
||||
.accordion-body {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: calc(100% - 32px);
|
||||
&.disabled {
|
||||
.accordion-left > :not(.toolhint),
|
||||
.accordion-actions,
|
||||
.accordion-body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.single) {
|
||||
min-height: 240px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 4px 0px;
|
||||
padding-top: 0px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.message-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
.accordion-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.message-label-anchor {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
cursor: text;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: inherit;
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
.accordion-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: all 0.15s ease;
|
||||
gap: 0.125rem;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
.hover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.delete:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
.hover-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.delete:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .hover-actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&:not(.disabled) .accordion-header .message-label {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,56 +1,118 @@
|
||||
import { IconTrash, IconWand } from '@tabler/icons';
|
||||
import { IconTrash, IconSend, IconChevronRight, IconChevronDown } from '@tabler/icons';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { get } from 'lodash';
|
||||
import invert from 'lodash/invert';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { autoDetectLang } from 'utils/codemirror/lang-detect';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import { queueWsMessage, isWsConnectionActive, connectWS } from 'utils/network/index';
|
||||
import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index';
|
||||
import toast from 'react-hot-toast';
|
||||
import WSRequestBodyMode from '../BodyMode/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
export const TYPE_BY_DECODER = {
|
||||
base64: 'binary',
|
||||
json: 'json',
|
||||
xml: 'xml'
|
||||
const codemirrorMode = {
|
||||
text: 'application/text',
|
||||
xml: 'application/xml',
|
||||
json: 'application/ld+json'
|
||||
};
|
||||
|
||||
export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER);
|
||||
// Maps stored type to display mode
|
||||
const typeToMode = (type) => {
|
||||
switch (type) {
|
||||
case 'json': return 'json';
|
||||
case 'xml': return 'xml';
|
||||
default: return 'text';
|
||||
}
|
||||
};
|
||||
|
||||
export const SingleWSMessage = ({
|
||||
message,
|
||||
item,
|
||||
collection,
|
||||
index,
|
||||
methodType,
|
||||
handleRun,
|
||||
canClientSendMultipleMessages,
|
||||
isLast
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isNew,
|
||||
onNewRendered,
|
||||
isSelected,
|
||||
onSelect
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
const { name, content, type } = message;
|
||||
const [messageFormat, setMessageFormat] = useState(autoDetectLang(content));
|
||||
const displayMode = typeToMode(type);
|
||||
const displayName = name || `message ${index + 1}`;
|
||||
|
||||
const onUpdateMessageType = (type) => {
|
||||
setMessageFormat(type);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(displayName);
|
||||
const labelTooltipId = `ws-msg-label-${message.uid ?? index}`;
|
||||
|
||||
// Auto-focus the name input when this is a newly created message
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
setIsEditing(true);
|
||||
setEditValue(displayName);
|
||||
onNewRendered();
|
||||
}
|
||||
}, [isNew]);
|
||||
|
||||
const saveName = (value) => {
|
||||
const trimmed = value.trim() || `message ${index + 1}`;
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
type: DECODER_BY_TYPE[type]
|
||||
name: trimmed
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
saveName(editValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditValue(displayName);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
saveName(editValue);
|
||||
};
|
||||
|
||||
const handleNameClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(displayName);
|
||||
setIsEditing(true);
|
||||
}, [displayName, onToggle]);
|
||||
|
||||
const fontSize = get(preferences, 'font.codeFontSize', 14);
|
||||
const lineHeight = fontSize * 1.5;
|
||||
|
||||
const editorHeight = useMemo(() => {
|
||||
const lineCount = (content || '').split('\n').length;
|
||||
const lines = lineCount + 1;
|
||||
return `${lines * lineHeight + 10}px`;
|
||||
}, [content, lineHeight]);
|
||||
|
||||
const onUpdateMessageType = (newMode) => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
type: typeToMode(newMode)
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -60,13 +122,11 @@ export const SingleWSMessage = ({
|
||||
|
||||
const onEdit = (value) => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages[index] = {
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
type: DECODER_BY_TYPE[messageFormat],
|
||||
...currentMessages[index],
|
||||
name: name || `message ${index + 1}`,
|
||||
content: value
|
||||
};
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -78,9 +138,7 @@ export const SingleWSMessage = ({
|
||||
|
||||
const onDeleteMessage = () => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages.splice(index, 1);
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -88,97 +146,122 @@ export const SingleWSMessage = ({
|
||||
}));
|
||||
};
|
||||
|
||||
let codeType = messageFormat;
|
||||
if (TYPE_BY_DECODER[type]) {
|
||||
codeType = TYPE_BY_DECODER[type];
|
||||
}
|
||||
const onSendMessage = useCallback(async () => {
|
||||
try {
|
||||
const col = findCollectionByUid(collections, collection.uid);
|
||||
const environment = findEnvironmentInCollection(col, col?.activeEnvironmentUid);
|
||||
|
||||
const codemirrorMode = {
|
||||
text: 'application/text',
|
||||
xml: 'application/xml',
|
||||
json: 'application/ld+json'
|
||||
};
|
||||
|
||||
const onPrettify = () => {
|
||||
if (codeType === 'json') {
|
||||
try {
|
||||
const prettyBodyJson = prettifyJsonString(content);
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
content: prettyBodyJson
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid JSON format.'));
|
||||
// Auto-connect if not already connected
|
||||
const connectionStatus = await isWsConnectionActive(item.uid);
|
||||
if (!connectionStatus.isActive) {
|
||||
await connectWS(item, col, environment, col?.runtimeVariables, { connectOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (codeType === 'xml') {
|
||||
try {
|
||||
const prettyBodyXML = xmlFormat(content, { collapseContent: true });
|
||||
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
content: prettyBodyXML
|
||||
};
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid XML format.'));
|
||||
const result = await queueWsMessage(item, col, environment, col?.runtimeVariables, index);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to send message');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
const isSingleMessage = !canClientSendMultipleMessages || body.ws.length === 1;
|
||||
}, [collections]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`message-container ${isSingleMessage ? 'single' : ''} ${isLast ? 'last' : ''}`}>
|
||||
<div className="message-toolbar">
|
||||
<span className="message-label">Message {index + 1}</span>
|
||||
<div className="toolbar-actions">
|
||||
<WSRequestBodyMode mode={messageFormat} onModeChange={onUpdateMessageType} />
|
||||
|
||||
<ToolHint text="Format" toolhintId={`prettify-msg-${index}`}>
|
||||
<button onClick={onPrettify} className="toolbar-btn">
|
||||
<IconWand size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
{index > 0 && (
|
||||
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
|
||||
<button onClick={onDeleteMessage} className="toolbar-btn delete">
|
||||
<IconTrash size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<StyledWrapper
|
||||
className={!isSelected ? 'disabled' : ''}
|
||||
onMouseDownCapture={() => {
|
||||
if (!isSelected) setTimeout(onSelect, 0);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="accordion-header"
|
||||
data-testid={`ws-message-header-${index}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="accordion-left">
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)}
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={(node) => node?.focus()}
|
||||
className="name-input"
|
||||
data-testid={`ws-message-name-input-${index}`}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameBlur}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<ToolHint
|
||||
text={displayName}
|
||||
toolhintId={labelTooltipId}
|
||||
className="message-label-anchor"
|
||||
place="bottom-start"
|
||||
positionStrategy="fixed"
|
||||
tooltipTestId="ws-message-name-tooltip"
|
||||
tooltipStyle={{ maxWidth: '320px', whiteSpace: 'normal', wordBreak: 'break-word' }}
|
||||
>
|
||||
<span
|
||||
className="message-label"
|
||||
data-testid={`ws-message-label-${index}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
onDoubleClick={handleNameClick}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</ToolHint>
|
||||
)}
|
||||
</div>
|
||||
<div className="accordion-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="hover-actions">
|
||||
<ToolHint text="Send" toolhintId={`send-msg-${index}`}>
|
||||
<button onClick={onSendMessage} className="hover-action-btn" data-testid={`ws-send-msg-${index}`}>
|
||||
<IconSend size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
{(body.ws || []).length > 1 && (
|
||||
<ToolHint text="Delete" toolhintId={`delete-msg-${index}`}>
|
||||
<button onClick={onDeleteMessage} className="hover-action-btn delete" data-testid={`ws-delete-msg-${index}`}>
|
||||
<IconTrash size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
)}
|
||||
</div>
|
||||
<WSRequestBodyMode mode={displayMode} onModeChange={onUpdateMessageType} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-container">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onRun={handleRun}
|
||||
onSave={onSave}
|
||||
mode={codemirrorMode[codeType] ?? 'text/plain'}
|
||||
enableVariableHighlighting={true}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="accordion-body" data-testid={`ws-message-body-${index}`} style={{ height: editorHeight }}>
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onRun={handleRun}
|
||||
onSave={onSave}
|
||||
mode={codemirrorMode[displayMode] ?? 'text/plain'}
|
||||
enableVariableHighlighting={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,21 +5,10 @@ const Wrapper = styled.div`
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.single {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.multi {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -36,13 +25,20 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.add-message-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
.add-message-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,99 +1,124 @@
|
||||
import { get } from 'lodash';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { IconPlus } from '@tabler/icons';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { SingleWSMessage } from './SingleWSMessage/index';
|
||||
|
||||
const WSBody = ({ item, collection, handleRun }) => {
|
||||
const getSelectedIndex = (messages) => {
|
||||
const idx = messages.findIndex((msg) => msg.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
};
|
||||
|
||||
const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
|
||||
const dispatch = useDispatch();
|
||||
const messagesContainerRef = useRef(null);
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const messages = body?.ws || [];
|
||||
|
||||
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
|
||||
const canClientSendMultipleMessages = false;
|
||||
const selectedIndex = getSelectedIndex(messages);
|
||||
|
||||
// Auto-scroll to the latest message when messages are added
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current && body?.ws?.length > 0) {
|
||||
const container = messagesContainerRef.current;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [body?.ws?.length]);
|
||||
|
||||
const addNewMessage = () => {
|
||||
const currentMessages = Array.isArray(body.ws) ? [...body.ws] : [];
|
||||
|
||||
currentMessages.push({
|
||||
name: `message ${currentMessages.length + 1}`,
|
||||
content: '{}'
|
||||
});
|
||||
// Expand the selected message by default (falls back to first)
|
||||
const [expandedUids, setExpandedUids] = useState(() => {
|
||||
const uid = messages[selectedIndex]?.uid || messages[0]?.uid;
|
||||
return new Set(uid ? [uid] : []);
|
||||
});
|
||||
const [newMessageUid, setNewMessageUid] = useState(null);
|
||||
const prevMessagesLengthRef = useRef(messages.length);
|
||||
|
||||
const setSelectedIndex = useCallback((index) => {
|
||||
const currentMessages = [...(body?.ws || [])];
|
||||
const updated = currentMessages.map((msg, i) => ({
|
||||
...msg,
|
||||
selected: i === index
|
||||
}));
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
content: updated,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
};
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
if (!body?.ws || !Array.isArray(body.ws)) {
|
||||
const toggleMessage = useCallback((uid) => {
|
||||
if (!uid) return;
|
||||
setExpandedUids((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(uid)) {
|
||||
next.delete(uid);
|
||||
} else {
|
||||
next.add(uid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((index) => {
|
||||
if (index !== selectedIndex) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
}, [selectedIndex, setSelectedIndex]);
|
||||
|
||||
// React to new message being added (messages.length increased)
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessagesLengthRef.current) {
|
||||
const newMsg = messages[messages.length - 1];
|
||||
if (newMsg?.uid) {
|
||||
setExpandedUids((prev) => new Set(prev).add(newMsg.uid));
|
||||
setNewMessageUid(newMsg.uid);
|
||||
setSelectedIndex(messages.length - 1);
|
||||
}
|
||||
}
|
||||
prevMessagesLengthRef.current = messages.length;
|
||||
}, [messages.length]);
|
||||
|
||||
const handleNewMessageRendered = useCallback(() => {
|
||||
setNewMessageUid(null);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new message is added
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current && messages.length > 0) {
|
||||
const container = messagesContainerRef.current;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
if (!messages.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="empty-state">
|
||||
<p>No WebSocket messages available</p>
|
||||
<Button
|
||||
onClick={addNewMessage}
|
||||
variant="filled"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Add Message
|
||||
</Button>
|
||||
<button className="add-message-link" data-testid="ws-add-message" onClick={onAddMessage}>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
<span>Add message</span>
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
|
||||
>
|
||||
{messagesToShow.map((message, index) => (
|
||||
<div ref={messagesContainerRef} className="messages-container">
|
||||
{messages.map((message, index) => (
|
||||
<SingleWSMessage
|
||||
key={index}
|
||||
key={message.uid}
|
||||
id={`ws-message-${message.uid}`}
|
||||
message={message}
|
||||
item={item}
|
||||
collection={collection}
|
||||
index={index}
|
||||
methodType={methodType}
|
||||
handleRun={handleRun}
|
||||
canClientSendMultipleMessages={canClientSendMultipleMessages}
|
||||
isLast={index === messagesToShow.length - 1}
|
||||
isExpanded={expandedUids.has(message.uid)}
|
||||
onToggle={() => toggleMessage(message.uid)}
|
||||
isNew={newMessageUid === message.uid}
|
||||
onNewRendered={handleNewMessageRendered}
|
||||
isSelected={selectedIndex === index}
|
||||
onSelect={() => handleSelect(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canClientSendMultipleMessages && (
|
||||
<div className="add-message-footer">
|
||||
<Button
|
||||
onClick={addNewMessage}
|
||||
variant="filled"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Add Message
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import VariablesEditor from 'components/VariablesEditor';
|
||||
import CollectionSettings from 'components/CollectionSettings';
|
||||
import { DocExplorer } from '@usebruno/graphql-docs';
|
||||
|
||||
import FileEditor from 'components/FileEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FolderSettings from 'components/FolderSettings';
|
||||
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
|
||||
@@ -42,7 +43,9 @@ import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
|
||||
import OpenAPISyncTab from 'components/OpenAPISyncTab';
|
||||
import OpenAPISpecTab from 'components/OpenAPISpecTab';
|
||||
import ChangelogTab from 'components/ChangelogTab';
|
||||
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
|
||||
import { clampRequestHeightForResponse } from './paneSize';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
@@ -51,6 +54,8 @@ const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_BOTTOM_PANE_HEIGHT = 150;
|
||||
const COLLAPSE_EDGE_THRESHOLD = 80;
|
||||
const EXPAND_EDGE_THRESHOLD = 100;
|
||||
// Minimum response pane height to show placeholder content on click-expand
|
||||
const RESPONSE_EXPAND_MIN_HEIGHT = 300;
|
||||
|
||||
const RequestTabPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -262,6 +267,21 @@ const RequestTabPanel = () => {
|
||||
startDragging(e);
|
||||
}, [expandResponse, applyPointerResize, startDragging]);
|
||||
|
||||
const handleResponseIndicatorClickExpand = useCallback(() => {
|
||||
expandResponse();
|
||||
if (!isVerticalLayoutRef.current || !mainSectionRef.current) return;
|
||||
const { height: containerHeight } = mainSectionRef.current.getBoundingClientRect();
|
||||
const clampedHeight = clampRequestHeightForResponse(
|
||||
topPaneHeight,
|
||||
containerHeight,
|
||||
RESPONSE_EXPAND_MIN_HEIGHT,
|
||||
MIN_TOP_PANE_HEIGHT
|
||||
);
|
||||
if (clampedHeight != null) {
|
||||
setTopPaneHeight(clampedHeight);
|
||||
}
|
||||
}, [expandResponse, topPaneHeight, setTopPaneHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
@@ -316,6 +336,10 @@ const RequestTabPanel = () => {
|
||||
return <Preferences />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'changelog') {
|
||||
return <ChangelogTab />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'workspaceOverview') {
|
||||
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
|
||||
}
|
||||
@@ -458,6 +482,17 @@ const RequestTabPanel = () => {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if (collection.fileMode) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<StyledWrapper className="flex flex-col flex-grow relative p-4 file-mode overflow-hidden">
|
||||
<FileEditor item={item} collection={collection} />
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const renderQueryUrl = () => {
|
||||
if (isGrpcRequest) {
|
||||
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
|
||||
@@ -563,7 +598,7 @@ const RequestTabPanel = () => {
|
||||
<CollapsedPanelIndicator
|
||||
panelType="response"
|
||||
isVertical={isVerticalLayout}
|
||||
onExpand={expandResponse}
|
||||
onExpand={handleResponseIndicatorClickExpand}
|
||||
onDragStart={handleResponseIndicatorDragStart}
|
||||
dragThresholdPx={isVerticalLayout ? MIN_BOTTOM_PANE_HEIGHT / 2 : MIN_RIGHT_PANE_WIDTH / 2}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Clamps the request pane height to leave room for the response pane.
|
||||
* Returns null if no clamping is needed.
|
||||
*/
|
||||
export const clampRequestHeightForResponse = (
|
||||
currentRequestHeight,
|
||||
containerHeight,
|
||||
minResponseHeight,
|
||||
minRequestHeight
|
||||
) => {
|
||||
const maxRequestHeight = containerHeight - minResponseHeight;
|
||||
if (currentRequestHeight <= maxRequestHeight) return null;
|
||||
return Math.max(minRequestHeight, maxRequestHeight);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { clampRequestHeightForResponse } from './paneSize';
|
||||
|
||||
// Mirrors RequestTabPanel's constants
|
||||
const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const RESPONSE_EXPAND_MIN_HEIGHT = 300;
|
||||
|
||||
const clamp = (currentRequestHeight, containerHeight) =>
|
||||
clampRequestHeightForResponse(currentRequestHeight, containerHeight, RESPONSE_EXPAND_MIN_HEIGHT, MIN_TOP_PANE_HEIGHT);
|
||||
|
||||
describe('clampRequestHeightForResponse', () => {
|
||||
it('shrinks the request pane so the response opens without squishing', () => {
|
||||
const containerHeight = 800;
|
||||
const result = clamp(760, containerHeight);
|
||||
expect(result).toBe(500);
|
||||
});
|
||||
|
||||
it('floors at the request minimum in a short window', () => {
|
||||
const containerHeight = 400;
|
||||
const result = clamp(380, containerHeight);
|
||||
expect(result).toBe(MIN_TOP_PANE_HEIGHT);
|
||||
});
|
||||
|
||||
it('floors at the request minimum even when the window cannot fit both panes', () => {
|
||||
// 200px container results in a negative maxRequestHeight, so the MIN_TOP_PANE_HEIGHT is returned.
|
||||
expect(clamp(380, 200)).toBe(MIN_TOP_PANE_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns null when the response already has enough room, no clamping needed', () => {
|
||||
// Request with height 400 leaves the response with enough room, so no clamping is needed.
|
||||
expect(clamp(400, 1000)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
IconX,
|
||||
IconCheck,
|
||||
IconFolder,
|
||||
IconUpload
|
||||
IconUpload,
|
||||
IconFileCode,
|
||||
IconFileOff
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -34,8 +37,6 @@ import { normalizePath } from 'utils/common/path';
|
||||
import classNames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
|
||||
const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -47,7 +48,6 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
// Get the current active workspace
|
||||
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const gitRootPath = collection?.git?.gitRootPath;
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
|
||||
// Workspace rename state
|
||||
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
|
||||
@@ -220,11 +220,20 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFileModeClick = () => {
|
||||
dispatch(
|
||||
toggleCollectionFileMode({
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Build overflow menu items for the "..." dropdown
|
||||
const overflowMenuItems = [
|
||||
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
|
||||
...(isOpenAPISyncEnabled && !hasOpenApiSyncConfigured
|
||||
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>, onClick: viewOpenApiSync }]
|
||||
{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick },
|
||||
...(!hasOpenApiSyncConfigured
|
||||
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }]
|
||||
: []),
|
||||
{ id: 'collection-settings', label: 'Collection Settings', leftSection: IconSettings, onClick: viewCollectionSettings }
|
||||
];
|
||||
@@ -576,7 +585,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
{!isScratchCollection && (
|
||||
<div className="flex flex-grow gap-1.5 items-center justify-end">
|
||||
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
|
||||
{isOpenAPISyncEnabled && hasOpenApiSyncConfigured && (
|
||||
{hasOpenApiSyncConfigured && (
|
||||
<ToolHint
|
||||
text={hasOpenApiError ? 'OpenAPI Error' : hasOpenApiUpdates ? 'OpenAPI Updates Available' : 'OpenAPI'}
|
||||
toolhintId="OpenApiSyncToolhintId"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode } from '@tabler/icons';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode, IconConfetti } from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
const getTabInfo = (type, tabName) => {
|
||||
@@ -92,7 +91,6 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
<>
|
||||
<OpenAPISyncIcon size={14} className="special-tab-icon flex-shrink-0" />
|
||||
<span className="ml-1 tab-name mr-1">OpenAPI</span>
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +102,14 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'changelog': {
|
||||
return (
|
||||
<>
|
||||
<IconConfetti size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">What's New</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -52,8 +52,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|| tab.type === 'graphql-request'
|
||||
|| tab.type === 'grpc-request'
|
||||
|| tab.type === 'ws-request';
|
||||
const shouldSyncUid = isRequestType || tab.type === 'folder-settings';
|
||||
|
||||
if (!isRequestType || !tab.pathname || !item?.uid || tab.uid === item.uid) {
|
||||
if (!shouldSyncUid || !tab.pathname || !item?.uid || tab.uid === item.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,7 +193,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
'workspaceOverview',
|
||||
'workspaceEnvironments',
|
||||
'openapi-sync',
|
||||
'openapi-spec'
|
||||
'openapi-spec',
|
||||
'changelog'
|
||||
];
|
||||
|
||||
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
|
||||
@@ -206,7 +208,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
// Close tab shortcut — draft-aware, only active for the focused tab
|
||||
useKeybinding('closeTab', () => {
|
||||
if (tab.type === 'request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') {
|
||||
if (tab.type === 'request' || tab.type === 'http-request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') {
|
||||
if (hasChanges) {
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,7 @@ import ErrorBanner from 'ui/ErrorBanner';
|
||||
import CodeSnippet from 'components/CodeSnippet';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { addTab, updateRequestPaneTab, updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { addTab, updateRequestPaneTab, updateScriptPaneTab, setFocusErrorLine } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
@@ -114,18 +114,28 @@ const ScriptErrorCard = ({ title, message, errorContext, item, collection, scrip
|
||||
const collectionSettingsTab = scriptPhase === 'test' ? 'tests' : 'script';
|
||||
const folderSettingsTab = scriptPhase === 'test' ? 'test' : 'script';
|
||||
|
||||
const errorLine = errorContext?.errorLine;
|
||||
const focusPayload = (uid) =>
|
||||
typeof errorLine === 'number'
|
||||
? { uid, scriptPhase, line: errorLine, requestedAt: Date.now() }
|
||||
: null;
|
||||
|
||||
if (sourceInfo.sourceType === 'collection') {
|
||||
dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' }));
|
||||
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: collectionSettingsTab }));
|
||||
if (collectionSettingsTab === 'script') {
|
||||
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: scriptPhase }));
|
||||
}
|
||||
const payload = focusPayload(collection.uid);
|
||||
if (payload) dispatch(setFocusErrorLine(payload));
|
||||
} else if (sourceInfo.sourceType === 'folder' && sourceInfo.sourceUid) {
|
||||
dispatch(addTab({ uid: sourceInfo.sourceUid, collectionUid: collection.uid, type: 'folder-settings' }));
|
||||
dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: sourceInfo.sourceUid, tab: folderSettingsTab }));
|
||||
if (folderSettingsTab === 'script') {
|
||||
dispatch(updateScriptPaneTab({ uid: sourceInfo.sourceUid, scriptPaneTab: scriptPhase }));
|
||||
}
|
||||
const payload = focusPayload(sourceInfo.sourceUid);
|
||||
if (payload) dispatch(setFocusErrorLine(payload));
|
||||
} else if (sourceInfo.sourceType === 'request') {
|
||||
dispatch(addTab({ uid: item.uid, collectionUid: collection.uid, type: 'request' }));
|
||||
if (scriptPhase === 'test') {
|
||||
@@ -134,6 +144,8 @@ const ScriptErrorCard = ({ title, message, errorContext, item, collection, scrip
|
||||
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'script' }));
|
||||
dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: scriptPhase }));
|
||||
}
|
||||
const payload = focusPayload(item.uid);
|
||||
if (payload) dispatch(setFocusErrorLine(payload));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type })
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tl-empty">No Body found</div>
|
||||
<div className="tl-empty">No Body</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ const Headers = ({ headers }) => {
|
||||
</button>
|
||||
{isOpen && (
|
||||
count === 0
|
||||
? <div className="tl-empty">No Headers found</div>
|
||||
? <div className="tl-empty">No Headers</div>
|
||||
: (
|
||||
<table className="tl-headers-table">
|
||||
<tbody>
|
||||
|
||||
@@ -21,6 +21,7 @@ const Status = ({ statusCode }) => {
|
||||
return (
|
||||
<span
|
||||
className="timeline-status"
|
||||
data-testid="timeline-status"
|
||||
style={{
|
||||
color,
|
||||
background,
|
||||
|
||||
@@ -137,7 +137,7 @@ const TimelineItem = ({
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`tl-row-wrap ${isOauth2 ? 'tl-row-wrap--oauth2' : ''}`}>
|
||||
<div className={`tl-row-wrap ${isOauth2 ? 'tl-row-wrap--oauth2' : ''}`} data-testid="timeline-entry">
|
||||
<div
|
||||
className={`tl-row ${isExpanded ? 'is-expanded' : ''}`}
|
||||
role="button"
|
||||
@@ -145,6 +145,7 @@ const TimelineItem = ({
|
||||
aria-expanded={isExpanded}
|
||||
onClick={toggleExpand}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
data-testid="timeline-item-header"
|
||||
>
|
||||
<div className="tl-col-chev">
|
||||
{isExpanded ? <IconChevronDown size={14} strokeWidth={2} /> : <IconChevronRight size={14} strokeWidth={2} />}
|
||||
@@ -155,9 +156,9 @@ const TimelineItem = ({
|
||||
<div className="tl-col-method">
|
||||
<Method method={method} />
|
||||
</div>
|
||||
<div className="tl-col-url" title={url}>{url}</div>
|
||||
<div className="tl-col-url" title={url} data-testid="timeline-url">{url}</div>
|
||||
<div className="tl-col-badge">
|
||||
<span className={badge.badgeClass}>{badge.badgeLabel}</span>
|
||||
<span className={badge.badgeClass} data-testid={`timeline-badge-${badge.kind}`}>{badge.badgeLabel}</span>
|
||||
</div>
|
||||
{!hideTimestamp && (
|
||||
<div className="tl-col-time">
|
||||
@@ -167,7 +168,7 @@ const TimelineItem = ({
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="tl-detail">
|
||||
<div className="tl-detail" data-testid="timeline-detail">
|
||||
<div className="tl-header">
|
||||
<div className="tl-header-url" title={`${method || ''} ${url}`}>
|
||||
<span className="tl-header-url-method">{method}</span>
|
||||
@@ -179,8 +180,9 @@ const TimelineItem = ({
|
||||
href="#"
|
||||
title={canNavigate ? `Open ${sourceFile}` : sourceFile}
|
||||
onClick={canNavigate ? handleNavigate : (ev) => ev.preventDefault()}
|
||||
data-testid="timeline-source-link"
|
||||
>
|
||||
<span className="tl-header-src-file">{sourceFile}</span>
|
||||
<span className="tl-header-src-file" data-testid="timeline-source-file">{sourceFile}</span>
|
||||
<span className="tl-header-src-icon">↗</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user