mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 01:48:33 +00:00
Compare commits
24 Commits
feature/en
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51312a3148 | ||
|
|
6e6804055d | ||
|
|
5904c36cdb | ||
|
|
8c997c46af | ||
|
|
700e25a1d5 | ||
|
|
c9059c9905 | ||
|
|
416b693afc | ||
|
|
bafb235e72 | ||
|
|
06dd5c14d5 | ||
|
|
0f0c2b5912 | ||
|
|
6664295b2b | ||
|
|
679cb91549 | ||
|
|
b9d9a27599 | ||
|
|
1fc703e4e3 | ||
|
|
89a0494e7e | ||
|
|
04806144a5 | ||
|
|
0c3d20b198 | ||
|
|
3ddf8e2a8b | ||
|
|
f10422cca6 | ||
|
|
ba166561cc | ||
|
|
3112380289 | ||
|
|
559946bcce | ||
|
|
e1c01ebe18 | ||
|
|
eb5dc12b43 |
70
.github/scripts/comment-on-flaky-tests.js
vendored
Normal file
70
.github/scripts/comment-on-flaky-tests.js
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Check if flaky-tests.json exists
|
||||
if (!fs.existsSync('flaky-tests.json')) {
|
||||
console.log('No flaky-tests.json found');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Get changed files in PR
|
||||
let changedFiles = [];
|
||||
try {
|
||||
changedFiles = execSync('git diff --name-only origin/main...HEAD')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(f => f.endsWith('.spec.ts'));
|
||||
} catch (error) {
|
||||
console.log('Could not determine changed files:', error.message);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No test files were modified in this PR');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Read flaky tests
|
||||
const flakyTests = JSON.parse(fs.readFileSync('flaky-tests.json', 'utf8'));
|
||||
|
||||
if (flakyTests.length === 0) {
|
||||
console.log('No flaky/failed tests found');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Find modified flaky tests
|
||||
const modifiedFlakyTests = flakyTests.filter(test =>
|
||||
changedFiles.some(file => test.file.includes(file))
|
||||
);
|
||||
|
||||
if (modifiedFlakyTests.length === 0) {
|
||||
console.log('No modified test files are flaky');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Generate comment markdown
|
||||
let comment = '## ⚠️ Warning: You modified flaky/failed test files\n\n';
|
||||
comment += 'The following test files you modified have reliability issues:\n\n';
|
||||
|
||||
modifiedFlakyTests.forEach(test => {
|
||||
const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky';
|
||||
comment += `### ${testType}: \`${test.file}\`\n`;
|
||||
comment += `**Test:** ${test.testTitle}\n`;
|
||||
comment += `**Status:** ${test.status}\n`;
|
||||
if (test.retryAttempt > 0) {
|
||||
comment += `**Retry Attempt:** ${test.retryAttempt}\n`;
|
||||
}
|
||||
comment += '\n**To debug locally, run:**\n';
|
||||
comment += '```bash\n';
|
||||
comment += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`;
|
||||
comment += '```\n\n';
|
||||
});
|
||||
|
||||
comment += '---\n';
|
||||
comment += '**Note:** Flaky tests passed after retrying, failed tests did not pass. ';
|
||||
comment += 'Please investigate and fix the root cause before merging.\n';
|
||||
|
||||
// Save comment to file for GitHub Action to post
|
||||
fs.writeFileSync('pr-comment.md', comment);
|
||||
|
||||
console.log(`Found ${modifiedFlakyTests.length} modified flaky tests`);
|
||||
78
.github/scripts/detect-flaky-tests.js
vendored
Normal file
78
.github/scripts/detect-flaky-tests.js
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
const fs = require('fs');
|
||||
|
||||
|
||||
// Read Playwright JSON report
|
||||
const resultsPath = 'playwright-report/results.json';
|
||||
|
||||
if (!fs.existsSync(resultsPath)) {
|
||||
console.log('No Playwright results found at', resultsPath);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const results = JSON.parse(fs.readFileSync(resultsPath, 'utf8'));
|
||||
|
||||
// Extract flaky tests
|
||||
// A test is flaky if: status === "passed" AND retry > 0
|
||||
// A test is failed if: status === "failed"
|
||||
// This means it failed initially but passed on retry OR failed completely
|
||||
const flakyTests = [];
|
||||
|
||||
function traverseSuites(suites) {
|
||||
for (const suite of suites) {
|
||||
// Process specs in this suite
|
||||
for (const spec of suite.specs || []) {
|
||||
for (const test of spec.tests || []) {
|
||||
// Check each test result
|
||||
for (const result of test.results || []) {
|
||||
// Track two types of problematic tests:
|
||||
// 1. Flaky: passed on a retry attempt (retry > 0)
|
||||
// 2. Failed: failed on all attempts
|
||||
if ((result.status === 'passed' && result.retry > 0) || result.status === 'failed') {
|
||||
flakyTests.push({
|
||||
file: spec.file,
|
||||
title: spec.title,
|
||||
testTitle: spec.title,
|
||||
line: spec.line,
|
||||
status: result.status,
|
||||
retryAttempt: result.retry
|
||||
});
|
||||
break; // Only record once per test
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process nested suites
|
||||
if (suite.suites && suite.suites.length > 0) {
|
||||
traverseSuites(suite.suites);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverseSuites(results.suites || []);
|
||||
|
||||
// Save flaky tests to JSON
|
||||
fs.writeFileSync('flaky-tests.json', JSON.stringify(flakyTests, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
let markdown = '## ⚠️ Flaky/Failed Tests Detected\n\n';
|
||||
markdown += 'The following tests are problematic:\n\n';
|
||||
|
||||
flakyTests.forEach(test => {
|
||||
const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky';
|
||||
markdown += `### ${testType}: \`${test.file}\`\n`;
|
||||
markdown += `- **Test:** ${test.testTitle}\n`;
|
||||
markdown += `- **Status:** ${test.status}\n`;
|
||||
if (test.retryAttempt > 0) {
|
||||
markdown += `- **Retry Attempt:** ${test.retryAttempt}\n`;
|
||||
}
|
||||
markdown += `- **Debug command:**\n`;
|
||||
markdown += '```bash\n';
|
||||
markdown += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`;
|
||||
markdown += '```\n\n';
|
||||
});
|
||||
|
||||
fs.writeFileSync('flaky-report.md', markdown);
|
||||
|
||||
console.log(`Found ${flakyTests.length} flaky/failed tests`);
|
||||
process.exit(flakyTests.length > 0 ? 1 : 0);
|
||||
119
.github/workflows/flaky-test-detector.yml
vendored
Normal file
119
.github/workflows/flaky-test-detector.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: Flaky Test Detector
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tests/**/*.spec.ts'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
detect-flaky-tests:
|
||||
name: Detect Flaky Tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Need full history to compare with main
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libgtk-3-0 libasound2t64 xvfb
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: |
|
||||
npm ci --legacy-peer-deps
|
||||
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Install test collection dependencies
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Build libraries
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
npm run build:schema-types
|
||||
npm run build:bruno-filestore
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: xvfb-run npm run test:e2e
|
||||
continue-on-error: true # Continue even if tests fail
|
||||
|
||||
- name: Detect flaky tests
|
||||
id: detect
|
||||
run: node .github/scripts/detect-flaky-tests.js
|
||||
continue-on-error: true # Don't fail workflow if flaky tests found
|
||||
|
||||
- name: Check modified flaky tests
|
||||
id: check-modified
|
||||
run: node .github/scripts/comment-on-flaky-tests.js
|
||||
continue-on-error: true
|
||||
|
||||
- name: Post PR comment
|
||||
if: hashFiles('pr-comment.md') != ''
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const comment = fs.readFileSync('pr-comment.md', 'utf8');
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
const botComment = comments.find(c =>
|
||||
c.user.type === 'Bot' && c.body.includes('Warning: You modified flaky/failed test files')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: comment
|
||||
});
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
|
||||
- name: Upload flaky test artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: flaky-test-results
|
||||
path: |
|
||||
flaky-tests.json
|
||||
flaky-report.md
|
||||
playwright-report/
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.shortcut {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
154
packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js
Normal file
154
packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconMenu2 } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const AppMenu = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'file',
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
id: 'open-collection',
|
||||
label: 'Open Collection',
|
||||
onClick: () => ipcRenderer?.invoke('renderer:open-collection')
|
||||
},
|
||||
{ type: 'divider', id: 'file-div-1' },
|
||||
{
|
||||
id: 'preferences',
|
||||
label: 'Preferences',
|
||||
rightSection: <span className="shortcut">Ctrl+,</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:open-preferences')
|
||||
},
|
||||
{ type: 'divider', id: 'file-div-2' },
|
||||
{
|
||||
id: 'quit',
|
||||
label: 'Quit',
|
||||
rightSection: <span className="shortcut">Alt+F4</span>,
|
||||
onClick: () => ipcRenderer?.send('renderer:window-close')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{
|
||||
id: 'undo',
|
||||
label: 'Undo',
|
||||
rightSection: <span className="shortcut">Ctrl+Z</span>,
|
||||
onClick: () => document.execCommand('undo')
|
||||
},
|
||||
{
|
||||
id: 'redo',
|
||||
label: 'Redo',
|
||||
rightSection: <span className="shortcut">Ctrl+Y</span>,
|
||||
onClick: () => document.execCommand('redo')
|
||||
},
|
||||
{ type: 'divider', id: 'edit-div-1' },
|
||||
{
|
||||
id: 'cut',
|
||||
label: 'Cut',
|
||||
rightSection: <span className="shortcut">Ctrl+X</span>,
|
||||
onClick: () => document.execCommand('cut')
|
||||
},
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
rightSection: <span className="shortcut">Ctrl+C</span>,
|
||||
onClick: () => document.execCommand('copy')
|
||||
},
|
||||
{
|
||||
id: 'paste',
|
||||
label: 'Paste',
|
||||
rightSection: <span className="shortcut">Ctrl+V</span>,
|
||||
onClick: () => document.execCommand('paste')
|
||||
},
|
||||
{ type: 'divider', id: 'edit-div-2' },
|
||||
{
|
||||
id: 'select-all',
|
||||
label: 'Select All',
|
||||
rightSection: <span className="shortcut">Ctrl+A</span>,
|
||||
onClick: () => document.execCommand('selectAll')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'view',
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
id: 'toggle-devtools',
|
||||
label: 'Developer Tools',
|
||||
rightSection: <span className="shortcut">Ctrl+Shift+I</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:toggle-devtools')
|
||||
},
|
||||
{ type: 'divider', id: 'view-div-1' },
|
||||
{
|
||||
id: 'reset-zoom',
|
||||
label: 'Reset Zoom',
|
||||
rightSection: <span className="shortcut">Ctrl+0</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:reset-zoom')
|
||||
},
|
||||
{
|
||||
id: 'zoom-in',
|
||||
label: 'Zoom In',
|
||||
rightSection: <span className="shortcut">Ctrl++</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:zoom-in')
|
||||
},
|
||||
{
|
||||
id: 'zoom-out',
|
||||
label: 'Zoom Out',
|
||||
rightSection: <span className="shortcut">Ctrl+-</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:zoom-out')
|
||||
},
|
||||
{ type: 'divider', id: 'view-div-2' },
|
||||
{
|
||||
id: 'toggle-fullscreen',
|
||||
label: 'Full Screen',
|
||||
rightSection: <span className="shortcut">F11</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:toggle-fullscreen')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About Bruno',
|
||||
onClick: () => ipcRenderer?.invoke('renderer:open-about')
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
label: 'Documentation',
|
||||
onClick: () => ipcRenderer?.invoke('renderer:open-docs')
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<MenuDropdown
|
||||
opened={isOpen}
|
||||
onChange={setIsOpen}
|
||||
placement="bottom-start"
|
||||
showTickMark={false}
|
||||
items={menuItems}
|
||||
>
|
||||
<ActionIcon label="Menu" size="lg">
|
||||
<IconMenu2 size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppMenu;
|
||||
@@ -210,6 +210,10 @@ const Wrapper = styled.div`
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Custom window control buttons for Windows - always interactive, above modal overlay */
|
||||
.window-controls {
|
||||
display: flex;
|
||||
|
||||
@@ -17,6 +17,7 @@ import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
|
||||
|
||||
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
|
||||
import AppMenu from './AppMenu';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
|
||||
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
|
||||
@@ -247,8 +248,9 @@ const AppTitleBar = () => {
|
||||
)}
|
||||
|
||||
<div className="titlebar-content">
|
||||
{/* Left section: Home + Workspace */}
|
||||
<div className="titlebar-left">
|
||||
{showWindowControls && <AppMenu />}
|
||||
|
||||
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import { isEqual, escapeRegExp } from 'lodash';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
|
||||
@@ -16,7 +16,7 @@ import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
@@ -34,6 +34,7 @@ export default class CodeEditor extends React.Component {
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
this.searchResultsCountElementId = 'search-results-count';
|
||||
this.searchBarRef = createRef();
|
||||
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
@@ -94,14 +95,14 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
this.setState({ searchBarVisible: true }, () => {
|
||||
this.searchBarRef.current?.focus();
|
||||
});
|
||||
},
|
||||
'Ctrl-F': (cm) => {
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
this.setState({ searchBarVisible: true }, () => {
|
||||
this.searchBarRef.current?.focus();
|
||||
});
|
||||
},
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
@@ -309,6 +310,10 @@ export default class CodeEditor extends React.Component {
|
||||
fontSize={this.props.fontSize}
|
||||
>
|
||||
<CodeMirrorSearch
|
||||
ref={(node) => {
|
||||
if (!node) return;
|
||||
this.searchBarRef.current = node;
|
||||
}}
|
||||
visible={this.state.searchBarVisible}
|
||||
editor={this.editor}
|
||||
onClose={() => this.setState({ searchBarVisible: false })}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -8,7 +8,7 @@ function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
|
||||
}
|
||||
|
||||
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
|
||||
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [regex, setRegex] = useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
@@ -19,6 +19,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
|
||||
const searchMarks = useRef([]);
|
||||
const searchLineHighlight = useRef(null);
|
||||
const searchMatches = useRef([]);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, 150);
|
||||
|
||||
@@ -106,6 +107,14 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
|
||||
}
|
||||
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
doSearch(0, debouncedSearchText);
|
||||
}, [debouncedSearchText, doSearch]);
|
||||
@@ -168,6 +177,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
|
||||
<StyledWrapper>
|
||||
<div className="bruno-search-bar">
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchText}
|
||||
@@ -196,6 +206,6 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default CodeMirrorSearch;
|
||||
|
||||
@@ -56,7 +56,7 @@ const Headers = ({ collection }) => {
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -64,7 +64,7 @@ const Headers = ({ collection }) => {
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
placeholder={!value ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -72,7 +72,7 @@ const Headers = ({ collection }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -80,7 +80,7 @@ const Headers = ({ collection }) => {
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,14 +46,14 @@ const VarsTable = ({ collection, vars, varType }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,37 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { IconTerminal2, IconPlus } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import SessionList from './SessionList';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
// Build xterm.js theme from app theme
|
||||
const getTerminalTheme = (theme) => {
|
||||
return {
|
||||
background: theme.console.bg,
|
||||
foreground: theme.console.messageColor,
|
||||
cursor: theme.console.messageColor,
|
||||
selectionBackground: theme.status.info.background,
|
||||
black: theme.background.base,
|
||||
red: theme.status.danger.text,
|
||||
green: theme.status.success.text,
|
||||
yellow: theme.status.warning.text,
|
||||
blue: theme.status.info.text,
|
||||
magenta: theme.colors.text.purple,
|
||||
cyan: theme.codemirror.variable.prompt,
|
||||
white: theme.text,
|
||||
brightBlack: theme.colors.text.muted,
|
||||
brightRed: theme.status.danger.text,
|
||||
brightGreen: theme.status.success.text,
|
||||
brightYellow: theme.status.warning.text,
|
||||
brightBlue: theme.status.info.text,
|
||||
brightMagenta: theme.colors.text.purple,
|
||||
brightCyan: theme.codemirror.variable.prompt,
|
||||
brightWhite: theme.text
|
||||
};
|
||||
};
|
||||
|
||||
// Terminal instances per session - Map<sessionId, { terminal, fitAddon, inputDisposable, resizeDisposable }>
|
||||
const terminalInstances = new Map();
|
||||
|
||||
@@ -33,7 +60,7 @@ const ensureParkingHost = () => {
|
||||
return parkingHost;
|
||||
};
|
||||
|
||||
const createTerminalForSession = (sessionId) => {
|
||||
const createTerminalForSession = (sessionId, terminalTheme) => {
|
||||
if (terminalInstances.has(sessionId)) {
|
||||
return terminalInstances.get(sessionId);
|
||||
}
|
||||
@@ -42,28 +69,7 @@ const createTerminalForSession = (sessionId) => {
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
selection: '#264f78',
|
||||
black: '#1e1e1e',
|
||||
red: '#f14c4c',
|
||||
green: '#23d18b',
|
||||
yellow: '#f5f543',
|
||||
blue: '#3b8eea',
|
||||
magenta: '#d670d6',
|
||||
cyan: '#29b8db',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5'
|
||||
},
|
||||
theme: terminalTheme,
|
||||
allowProposedApi: true
|
||||
});
|
||||
|
||||
@@ -156,10 +162,10 @@ const cleanupTerminalInstance = (sessionId) => {
|
||||
}
|
||||
};
|
||||
|
||||
const openTerminalIntoContainer = async (container, sessionId) => {
|
||||
const openTerminalIntoContainer = async (container, sessionId, terminalTheme) => {
|
||||
if (!container || !sessionId) return;
|
||||
|
||||
const instance = createTerminalForSession(sessionId);
|
||||
const instance = createTerminalForSession(sessionId, terminalTheme);
|
||||
const { terminal, fitAddon } = instance;
|
||||
|
||||
if (!terminal.element) {
|
||||
@@ -212,6 +218,8 @@ const TerminalTab = () => {
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { theme } = useTheme();
|
||||
const terminalTheme = getTerminalTheme(theme);
|
||||
|
||||
// Load sessions list
|
||||
const loadSessions = useCallback(async (currentActiveSessionId = null) => {
|
||||
@@ -355,6 +363,15 @@ const TerminalTab = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update all terminal themes when app theme changes
|
||||
useEffect(() => {
|
||||
terminalInstances.forEach((instance) => {
|
||||
if (instance.terminal) {
|
||||
instance.terminal.options.theme = terminalTheme;
|
||||
}
|
||||
});
|
||||
}, [theme.mode]);
|
||||
|
||||
// Handle terminal display for active session
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !terminalRef.current) return;
|
||||
@@ -362,7 +379,7 @@ const TerminalTab = () => {
|
||||
let mounted = true;
|
||||
|
||||
const setupTerminal = async () => {
|
||||
await openTerminalIntoContainer(terminalRef.current, activeSessionId);
|
||||
await openTerminalIntoContainer(terminalRef.current, activeSessionId, terminalTheme);
|
||||
|
||||
if (mounted) {
|
||||
const instance = terminalInstances.get(activeSessionId);
|
||||
|
||||
@@ -173,6 +173,18 @@ const Wrapper = styled.div`
|
||||
background-color: ${(props) => props.theme.dropdown.separator};
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.submenu-trigger {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import Tippy from '@tippyjs/react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, ...props }) => {
|
||||
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, onMouseEnter, onMouseLeave, ...props }) => {
|
||||
// When in controlled mode (visible prop is provided), don't use trigger prop
|
||||
const tippyProps = visible !== undefined
|
||||
? { ...props, visible, interactive: true, appendTo: appendTo || 'parent' }
|
||||
@@ -11,7 +11,14 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, a
|
||||
return (
|
||||
<Tippy
|
||||
render={(attrs) => (
|
||||
<StyledWrapper className="tippy-box dropdown" transparent={transparent} tabIndex={-1} {...attrs}>
|
||||
<StyledWrapper
|
||||
className="tippy-box dropdown"
|
||||
transparent={transparent}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</StyledWrapper>
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,13 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
}
|
||||
@@ -24,6 +29,7 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.table.thead.color} !important;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
user-select: none;
|
||||
overflow: visible;
|
||||
|
||||
border: none !important;
|
||||
|
||||
@@ -34,10 +40,36 @@ const StyledWrapper = styled.div`
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0};
|
||||
border-right: solid 1px ${(props) => props.theme.border.border0};
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.column-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 100;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +93,32 @@ const StyledWrapper = styled.div`
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0};
|
||||
border-right: solid 1px ${(props) => props.theme.border.border0};
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Handle CodeMirror editors overflow */
|
||||
.cm-editor {
|
||||
max-width: 100%;
|
||||
|
||||
.cm-scroller {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { uuid } from 'utils/common';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
|
||||
const EditableTable = ({
|
||||
columns,
|
||||
rows,
|
||||
@@ -23,7 +25,101 @@ const EditableTable = ({
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [dragStart, setDragStart] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
const initialWidths = {};
|
||||
columns.forEach((col) => {
|
||||
initialWidths[col.key] = col.width || 'auto';
|
||||
});
|
||||
return initialWidths;
|
||||
});
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const currentCell = e.target.closest('td');
|
||||
const nextCell = currentCell?.nextElementSibling;
|
||||
if (!currentCell || !nextCell) return;
|
||||
|
||||
const columnIndex = columns.findIndex((col) => col.key === columnKey);
|
||||
if (columnIndex >= columns.length - 1) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = currentCell.offsetWidth;
|
||||
const nextColumnKey = columns[columnIndex + 1].key;
|
||||
const nextColumnStartWidth = nextCell.offsetWidth;
|
||||
|
||||
setResizing(columnKey);
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
const diff = moveEvent.clientX - startX;
|
||||
const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH;
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Convert pixel widths to percentages for responsive scaling
|
||||
const table = tableRef.current?.querySelector('table');
|
||||
if (table) {
|
||||
const tableWidth = table.offsetWidth;
|
||||
const headerCells = table.querySelectorAll('thead td');
|
||||
const newWidths = {};
|
||||
|
||||
headerCells.forEach((cell, cellIndex) => {
|
||||
const checkboxOffset = showCheckbox ? 1 : 0;
|
||||
const colIndex = cellIndex - checkboxOffset;
|
||||
|
||||
if (colIndex >= 0 && colIndex < columns.length) {
|
||||
const colKey = columns[colIndex]?.key;
|
||||
if (colKey) {
|
||||
const percentage = (cell.offsetWidth / tableWidth) * 100;
|
||||
newWidths[colKey] = `${percentage}%`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(newWidths).length > 0) {
|
||||
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||
}
|
||||
}
|
||||
setResizing(null);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
const table = tableRef.current?.querySelector('table');
|
||||
if (!table) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setTableHeight(table.offsetHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
resizeObserver.observe(table);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return columnWidths[column.key] || column.width || 'auto';
|
||||
}, [columnWidths]);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
@@ -138,15 +234,9 @@ const EditableTable = ({
|
||||
onChange(filteredRows);
|
||||
}, [rows, onChange]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
if (column.width) return column.width;
|
||||
return 'auto';
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((e, index) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', index);
|
||||
setDragStart(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e, index) => {
|
||||
@@ -163,19 +253,16 @@ const EditableTable = ({
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
if (!movedRow) {
|
||||
setDragStart(null);
|
||||
setHoveredRow(null);
|
||||
return;
|
||||
}
|
||||
updatedOrder.splice(toIndex, 0, movedRow);
|
||||
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
|
||||
}
|
||||
setDragStart(null);
|
||||
setHoveredRow(null);
|
||||
}, [onReorder, rowsWithEmpty, showAddRow]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragStart(null);
|
||||
setHoveredRow(null);
|
||||
}, []);
|
||||
|
||||
@@ -225,7 +312,7 @@ const EditableTable = ({
|
||||
className="mousetrap"
|
||||
value={value || ''}
|
||||
readOnly={column.readOnly}
|
||||
placeholder={isEmpty ? column.placeholder || column.name : ''}
|
||||
placeholder={!value ? column.placeholder || column.name : ''}
|
||||
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
|
||||
/>
|
||||
{errorIcon}
|
||||
@@ -236,7 +323,7 @@ const EditableTable = ({
|
||||
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
|
||||
|
||||
return (
|
||||
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
|
||||
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
|
||||
<div className="table-container" ref={tableRef} data-testid={testId}>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -244,12 +331,19 @@ const EditableTable = ({
|
||||
{showCheckbox && (
|
||||
<td className="text-center">{checkboxLabel}</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ width: getColumnWidth(column) }}
|
||||
>
|
||||
{column.name}
|
||||
<span className="column-name">{column.name}</span>
|
||||
{colIndex < columns.length - 1 && (
|
||||
<div
|
||||
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, column.key)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
|
||||
@@ -6,6 +6,11 @@ const Wrapper = styled.div`
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
@@ -32,10 +37,6 @@ const Wrapper = styled.div`
|
||||
&:nth-child(5) {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
thead {
|
||||
@@ -48,10 +49,26 @@ const Wrapper = styled.div`
|
||||
padding: 5px 10px !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0};
|
||||
border-right: solid 1px ${(props) => props.theme.border.border0};
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 100;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,21 +164,6 @@ const Wrapper = styled.div`
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.discard {
|
||||
padding: 6px 16px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,546 @@
|
||||
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 { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
|
||||
const MIN_H = 35 * 2;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item }) => (
|
||||
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
(prevProps, nextProps) => {
|
||||
const prevUid = prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.uid;
|
||||
return prevUid === nextUid && prevProps.children === nextProps.children;
|
||||
}
|
||||
);
|
||||
|
||||
const EnvironmentVariablesTable = ({
|
||||
environment,
|
||||
collection,
|
||||
onSave,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onDraftClear,
|
||||
setIsModified,
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
}) => {
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
const [resizing, setResizing] = useState(null);
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const currentCell = e.target.closest('td');
|
||||
const nextCell = currentCell?.nextElementSibling;
|
||||
if (!currentCell || !nextCell) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = currentCell.offsetWidth;
|
||||
const nextColumnKey = 'value';
|
||||
const nextColumnStartWidth = nextCell.offsetWidth;
|
||||
|
||||
setResizing(columnKey);
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
const diff = moveEvent.clientX - startX;
|
||||
const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH;
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths({
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(null);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, []);
|
||||
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}, [environment.uid, environment.variables]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues,
|
||||
validationSchema: Yup.array().of(
|
||||
Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
name: Yup.string().when('$isLastRow', {
|
||||
is: true,
|
||||
then: (schema) => schema.optional(),
|
||||
otherwise: (schema) =>
|
||||
schema
|
||||
.required('Name cannot be empty')
|
||||
.matches(
|
||||
variableNameRegex,
|
||||
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
|
||||
)
|
||||
.trim()
|
||||
}),
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable()
|
||||
})
|
||||
),
|
||||
validate: (values) => {
|
||||
const errors = {};
|
||||
values.forEach((variable, index) => {
|
||||
const isLastRow = index === values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name cannot be empty';
|
||||
} else if (!variableNameRegex.test(variable.name)) {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name
|
||||
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
|
||||
}
|
||||
});
|
||||
return Object.keys(errors).length > 0 ? errors : {};
|
||||
},
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
// Restore draft values on mount or environment switch
|
||||
useEffect(() => {
|
||||
const isMount = !mountedRef.current;
|
||||
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
|
||||
|
||||
prevEnvUidRef.current = environment.uid;
|
||||
mountedRef.current = true;
|
||||
|
||||
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
|
||||
formik.setValues([
|
||||
...draft.variables,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify(environment.variables || []);
|
||||
}, [environment.variables]);
|
||||
|
||||
// Sync modified state
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
setIsModified(hasActualChanges);
|
||||
}, [formik.values, savedValuesJson, setIsModified]);
|
||||
|
||||
// Sync draft state
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
|
||||
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
|
||||
|
||||
if (hasActualChanges) {
|
||||
if (currentValuesJson !== existingDraftJson) {
|
||||
onDraftChange(currentValues);
|
||||
}
|
||||
} else if (hasDraftForThisEnv) {
|
||||
onDraftClear();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [formik.values, savedValuesJson, environment.uid, hasDraftForThisEnv, draft?.variables, onDraftChange, onDraftClear]);
|
||||
|
||||
const ErrorMessage = ({ name, index }) => {
|
||||
const meta = formik.getFieldMeta(name);
|
||||
const id = `error-${name}-${index}`;
|
||||
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const variable = formik.values[index];
|
||||
const isEmptyRow = !variable?.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!meta.error || !meta.touched) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
|
||||
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveVar = useCallback(
|
||||
(id) => {
|
||||
const currentValues = formik.values;
|
||||
|
||||
if (!currentValues || currentValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastRow = currentValues[currentValues.length - 1];
|
||||
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
|
||||
|
||||
if (isLastEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
|
||||
|
||||
const hasEmptyLastRow
|
||||
= filteredValues.length > 0
|
||||
&& (!filteredValues[filteredValues.length - 1].name
|
||||
|| filteredValues[filteredValues.length - 1].name.trim() === '');
|
||||
|
||||
const newValues = hasEmptyLastRow
|
||||
? filteredValues
|
||||
: [
|
||||
...filteredValues,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
|
||||
formik.setValues(newValues);
|
||||
},
|
||||
[formik.values]
|
||||
);
|
||||
|
||||
const handleNameChange = (index, e) => {
|
||||
formik.handleChange(e);
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
|
||||
if (isLastRow) {
|
||||
const newVariable = {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
};
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, newVariable, false);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = (index) => {
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (index, e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValidationErrors = variablesToSave.some((variable) => {
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
if (!variableNameRegex.test(variable.name)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasValidationErrors) {
|
||||
toast.error('Please fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(cloneDeep(variablesToSave))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
}, [formik.values, environment.variables, onSave, setIsModified]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const originalVars = environment.variables || [];
|
||||
const resetValues = [
|
||||
...originalVars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: resetValues });
|
||||
setIsModified(false);
|
||||
}, [environment.variables, setIsModified]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveEvent = () => {
|
||||
handleSaveRef.current();
|
||||
};
|
||||
|
||||
window.addEventListener('environment-save', handleSaveEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('environment-save', handleSaveEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredVariables = useMemo(() => {
|
||||
const allVariables = formik.values.map((variable, index) => ({ variable, index }));
|
||||
if (!searchQuery?.trim()) {
|
||||
return allVariables;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
return allVariables.filter(({ variable, index }) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
|
||||
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
|
||||
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
components={{ TableRow }}
|
||||
data={filteredVariables}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
Name
|
||||
<div
|
||||
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ width: columnWidths.value }}>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(index, item) => item.variable.uid}
|
||||
itemContent={(index, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onBlur={() => handleNameBlur(actualIndex)}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
|
||||
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"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{renderExtraValueContent && renderExtraValueContent(variable)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentVariablesTable;
|
||||
@@ -0,0 +1,105 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.collapsed {
|
||||
flex-shrink: 0;
|
||||
|
||||
.section-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.section-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
|
||||
.section-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-radius: 10px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 4px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { IconChevronRight } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollapsibleSection = ({
|
||||
title,
|
||||
expanded,
|
||||
onToggle,
|
||||
badge,
|
||||
actions,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-title-wrapper">
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className={`section-icon ${expanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
<span className="section-title">{title}</span>
|
||||
{badge !== undefined && badge !== null && (
|
||||
<span className="section-badge">{badge}</span>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="section-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="section-content">
|
||||
{children}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleSection;
|
||||
@@ -0,0 +1,93 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.bg};
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 8px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid ${(props) => props.theme.border.border0};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.toggle-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:first-child {
|
||||
border-right: 1px solid ${(props) => props.theme.border.border0};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.brand};
|
||||
color: ${(props) => props.theme.bg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.delete-btn:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import DeleteDotEnvFile from 'components/Environments/EnvironmentSettings/DeleteDotEnvFile';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DotEnvFileDetails = ({
|
||||
title,
|
||||
children,
|
||||
onDelete,
|
||||
dotEnvExists,
|
||||
viewMode,
|
||||
onViewModeChange
|
||||
}) => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="header">
|
||||
<h3 className="title">{title}</h3>
|
||||
<div className="actions">
|
||||
{dotEnvExists && (
|
||||
<>
|
||||
<div className="view-toggle" role="group" aria-label="View mode">
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-btn ${viewMode === 'table' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('table')}
|
||||
aria-pressed={viewMode === 'table'}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('raw')}
|
||||
aria-pressed={viewMode === 'raw'}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={handleDeleteClick} title="Delete .env file" className="action-btn delete-btn">
|
||||
<IconTrash size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDeleteModal && (
|
||||
<DeleteDotEnvFile
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
filename={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="content">
|
||||
{children}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DotEnvFileDetails;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { IconFileOff } from '@tabler/icons';
|
||||
|
||||
const DotEnvEmptyState = () => {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<IconFileOff size={48} strokeWidth={1.5} />
|
||||
<div className="title">No .env File</div>
|
||||
<div className="description">
|
||||
Add a variable below to create a .env file in this location.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DotEnvEmptyState;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { IconAlertCircle } from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
|
||||
const DotEnvErrorMessage = React.memo(({ formik, name, index }) => {
|
||||
const meta = formik.getFieldMeta(name);
|
||||
const id = `error-${name}-${index}`;
|
||||
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const variable = formik.values[index];
|
||||
const isEmptyRow = !variable?.name || variable.name.trim() === '';
|
||||
|
||||
if ((isLastRow && isEmptyRow) || !meta.error || !meta.touched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
|
||||
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default DotEnvErrorMessage;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
|
||||
const DotEnvRawView = ({
|
||||
collection,
|
||||
item,
|
||||
theme,
|
||||
value,
|
||||
onChange,
|
||||
onSave,
|
||||
onReset,
|
||||
isSaving
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="raw-editor-container">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
theme={theme}
|
||||
value={value}
|
||||
onEdit={onChange}
|
||||
onSave={onSave}
|
||||
mode="text/plain"
|
||||
enableVariableHighlighting={false}
|
||||
enableBrunoVarInfo={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv-raw">
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv-raw">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DotEnvRawView;
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import DotEnvErrorMessage from './DotEnvErrorMessage';
|
||||
import { MIN_TABLE_HEIGHT } from './utils';
|
||||
|
||||
const TableRow = React.memo(({ children, item }) => (
|
||||
<tr key={item.uid} data-testid={`dotenv-var-row-${item.name}`}>{children}</tr>
|
||||
), (prevProps, nextProps) => {
|
||||
const prevUid = prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.uid;
|
||||
return prevUid === nextUid && prevProps.children === nextProps.children;
|
||||
});
|
||||
|
||||
const DotEnvTableView = ({
|
||||
formik,
|
||||
theme,
|
||||
showValueColumn,
|
||||
tableHeight,
|
||||
onHeightChange,
|
||||
onNameChange,
|
||||
onNameBlur,
|
||||
onNameKeyDown,
|
||||
onRemoveVar,
|
||||
onSave,
|
||||
onReset,
|
||||
isSaving
|
||||
}) => {
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
onHeightChange(h);
|
||||
}, [onHeightChange]);
|
||||
|
||||
// Use refs for stable access to formik values in callbacks
|
||||
const formikRef = useRef(formik);
|
||||
formikRef.current = formik;
|
||||
|
||||
// Don't memoize itemContent - TableVirtuoso handles this internally
|
||||
// and we need fresh access to formik values
|
||||
const itemContent = (index, variable) => {
|
||||
const currentFormik = formikRef.current;
|
||||
const isLastRow = index === currentFormik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${index}.name`}
|
||||
name={`${index}.name`}
|
||||
value={variable.name}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
onChange={(e) => onNameChange(index, e)}
|
||||
onBlur={() => onNameBlur(index)}
|
||||
onKeyDown={(e) => onNameKeyDown(index, e)}
|
||||
/>
|
||||
<DotEnvErrorMessage formik={currentFormik} name={`${index}.name`} index={index} />
|
||||
</div>
|
||||
</td>
|
||||
{showValueColumn && (
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={theme}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
onChange={(newValue) => currentFormik.setFieldValue(`${index}.value`, newValue, true)}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="delete-col">
|
||||
{!isLastEmptyRow && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete variable"
|
||||
onClick={() => onRemoveVar(variable.uid)}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight || MIN_TABLE_HEIGHT }}
|
||||
components={{ TableRow }}
|
||||
data={formik.values}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
{showValueColumn && <td>Value</td>}
|
||||
<td className="delete-col"></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(index, variable) => variable.uid}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv">
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DotEnvTableView;
|
||||
@@ -1,11 +1,22 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
.raw-editor-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
|
||||
.CodeMirror {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
@@ -16,24 +27,20 @@ const Wrapper = styled.div`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
|
||||
td {
|
||||
vertical-align: middle;
|
||||
padding: 2px 10px;
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 25px;
|
||||
border-right: none;
|
||||
}
|
||||
&:nth-child(4) {
|
||||
width: 80px;
|
||||
}
|
||||
&:nth-child(5) {
|
||||
width: 60px;
|
||||
&:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 30%;
|
||||
&.delete-col {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,30 +49,30 @@ const Wrapper = styled.div`
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
|
||||
|
||||
td {
|
||||
padding: 5px 10px !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0};
|
||||
border-right: solid 1px ${(props) => props.theme.border.border0};
|
||||
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
||||
td {
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0};
|
||||
border-right: solid 1px ${(props) => props.theme.border.border0};
|
||||
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
@@ -101,12 +108,78 @@ const Wrapper = styled.div`
|
||||
vertical-align: middle;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 12px 2px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit {
|
||||
padding: 6px 16px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: none;
|
||||
background: ${(props) => props.theme.brand};
|
||||
color: ${(props) => props.theme.bg};
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.reset {
|
||||
background: transparent;
|
||||
padding: 6px 16px;
|
||||
color: ${(props) => props.theme.brand};
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DotEnvTableView from './DotEnvTableView';
|
||||
import DotEnvRawView from './DotEnvRawView';
|
||||
import DotEnvEmptyState from './DotEnvEmptyState';
|
||||
import { variablesToRaw, rawToVariables, MIN_TABLE_HEIGHT } from './utils';
|
||||
|
||||
const DotEnvFileEditor = ({
|
||||
variables,
|
||||
onSave,
|
||||
onSaveRaw,
|
||||
isModified,
|
||||
setIsModified,
|
||||
dotEnvExists,
|
||||
rawContent,
|
||||
viewMode = 'table',
|
||||
collection,
|
||||
item
|
||||
}) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const [tableHeight, setTableHeight] = useState(MIN_TABLE_HEIGHT);
|
||||
// Derive a single baseline raw value for consistent dirty-tracking
|
||||
const baselineRaw = rawContent ?? variablesToRaw(variables || []);
|
||||
const initialRawValue = baselineRaw;
|
||||
const [rawValue, setRawValue] = useState(initialRawValue);
|
||||
const [prevViewMode, setPrevViewMode] = useState(viewMode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const formikRef = useRef(null);
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = (variables || []).map((v) => ({
|
||||
...v,
|
||||
uid: v.uid || uuid()
|
||||
}));
|
||||
return [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: ''
|
||||
}
|
||||
];
|
||||
}, [variables]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues,
|
||||
validate: (values) => {
|
||||
const errors = {};
|
||||
values.forEach((variable, index) => {
|
||||
const isLastRow = index === values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name cannot be empty';
|
||||
} else if (!variableNameRegex.test(variable.name)) {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name
|
||||
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
|
||||
}
|
||||
});
|
||||
return Object.keys(errors).length > 0 ? errors : {};
|
||||
},
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
formikRef.current = formik;
|
||||
|
||||
// Sync raw value with external changes
|
||||
useEffect(() => {
|
||||
setRawValue(baselineRaw);
|
||||
}, [baselineRaw]);
|
||||
|
||||
// Handle view mode switching
|
||||
useEffect(() => {
|
||||
if (viewMode !== prevViewMode) {
|
||||
if (viewMode === 'raw' && prevViewMode === 'table') {
|
||||
const currentVars = formikRef.current.values.filter((v) => v.name && v.name.trim() !== '');
|
||||
const newRawValue = variablesToRaw(currentVars);
|
||||
setRawValue(newRawValue);
|
||||
} else if (viewMode === 'table' && prevViewMode === 'raw') {
|
||||
const parsedVars = rawToVariables(rawValue);
|
||||
const newValues = [
|
||||
...parsedVars,
|
||||
{ uid: uuid(), name: '', value: '' }
|
||||
];
|
||||
formikRef.current.setValues(newValues);
|
||||
}
|
||||
setPrevViewMode(viewMode);
|
||||
}
|
||||
}, [viewMode, prevViewMode, rawValue]);
|
||||
|
||||
const normalizeForComparison = (vars) => {
|
||||
return vars
|
||||
.filter((v) => v.name && v.name.trim() !== '')
|
||||
.map(({ name, value }) => ({ name, value: value || '' }));
|
||||
};
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify(normalizeForComparison(variables || []));
|
||||
}, [variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'raw') {
|
||||
const hasRawChanges = rawValue !== baselineRaw;
|
||||
setIsModified(hasRawChanges);
|
||||
} else {
|
||||
const currentValuesJson = JSON.stringify(normalizeForComparison(formik.values));
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
setIsModified(hasActualChanges);
|
||||
}
|
||||
}, [formik.values, savedValuesJson, setIsModified, viewMode, rawValue, baselineRaw]);
|
||||
|
||||
// Ref for stable formik.values access
|
||||
const valuesRef = useRef(formik.values);
|
||||
valuesRef.current = formik.values;
|
||||
|
||||
const handleRemoveVar = useCallback((id) => {
|
||||
const currentValues = valuesRef.current;
|
||||
|
||||
if (!currentValues || currentValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastRow = currentValues[currentValues.length - 1];
|
||||
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
|
||||
|
||||
if (isLastEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
|
||||
|
||||
const hasEmptyLastRow
|
||||
= filteredValues.length > 0
|
||||
&& (!filteredValues[filteredValues.length - 1].name
|
||||
|| filteredValues[filteredValues.length - 1].name.trim() === '');
|
||||
|
||||
const newValues = hasEmptyLastRow
|
||||
? filteredValues
|
||||
: [
|
||||
...filteredValues,
|
||||
{ uid: uuid(), name: '', value: '' }
|
||||
];
|
||||
|
||||
formikRef.current.setValues(newValues);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = useCallback((index, e) => {
|
||||
formik.handleChange(e);
|
||||
const isLastRow = index === valuesRef.current.length - 1;
|
||||
|
||||
if (isLastRow) {
|
||||
const newVariable = { uid: uuid(), name: '', value: '' };
|
||||
setTimeout(() => {
|
||||
formik.setValues((prev) => {
|
||||
const lastRow = prev[prev.length - 1];
|
||||
if (lastRow?.name?.trim()) {
|
||||
return [...prev, newVariable];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNameBlur = useCallback((index) => {
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
}, []);
|
||||
|
||||
const handleNameKeyDown = useCallback((index, e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
|
||||
const hasValidationErrors = variablesToSave.some((variable) => {
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
if (!variableNameRegex.test(variable.name)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasValidationErrors) {
|
||||
toast.error('Please fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
onSave(variablesToSave)
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{ uid: uuid(), name: '', value: '' }
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
});
|
||||
}, [isSaving, formik.values, onSave, setIsModified]);
|
||||
|
||||
const handleSaveRaw = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
|
||||
if (!onSaveRaw) {
|
||||
toast.error('Raw save is not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
onSaveRaw(rawValue)
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
});
|
||||
}, [isSaving, rawValue, onSaveRaw, setIsModified]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (viewMode === 'raw') {
|
||||
setRawValue(baselineRaw);
|
||||
setIsModified(false);
|
||||
} else {
|
||||
const originalVars = (variables || []).map((v) => ({
|
||||
...v,
|
||||
uid: v.uid || uuid()
|
||||
}));
|
||||
const resetValues = [
|
||||
...originalVars,
|
||||
{ uid: uuid(), name: '', value: '' }
|
||||
];
|
||||
formik.resetForm({ values: resetValues });
|
||||
setIsModified(false);
|
||||
}
|
||||
}, [viewMode, baselineRaw, variables, setIsModified]);
|
||||
|
||||
const handleRawChange = useCallback((newValue) => {
|
||||
setRawValue(newValue);
|
||||
}, []);
|
||||
|
||||
// Global save event listener
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const handleSaveRawRef = useRef(handleSaveRaw);
|
||||
handleSaveRawRef.current = handleSaveRaw;
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveEvent = () => {
|
||||
if (viewMode === 'raw') {
|
||||
handleSaveRawRef.current();
|
||||
} else {
|
||||
handleSaveRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dotenv-save', handleSaveEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dotenv-save', handleSaveEvent);
|
||||
};
|
||||
}, [viewMode]);
|
||||
|
||||
// Raw view mode
|
||||
if (viewMode === 'raw') {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<DotEnvRawView
|
||||
collection={collection}
|
||||
item={item}
|
||||
theme={displayedTheme}
|
||||
value={rawValue}
|
||||
onChange={handleRawChange}
|
||||
onSave={handleSaveRaw}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state (no .env file exists yet)
|
||||
const showEmptyState = !dotEnvExists && (!variables || variables.length === 0);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{showEmptyState && <DotEnvEmptyState />}
|
||||
<DotEnvTableView
|
||||
formik={formik}
|
||||
theme={displayedTheme}
|
||||
showValueColumn={!showEmptyState}
|
||||
tableHeight={showEmptyState ? MIN_TABLE_HEIGHT : tableHeight}
|
||||
onHeightChange={setTableHeight}
|
||||
onNameChange={handleNameChange}
|
||||
onNameBlur={handleNameBlur}
|
||||
onNameKeyDown={handleNameKeyDown}
|
||||
onRemoveVar={handleRemoveVar}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DotEnvFileEditor;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { uuid } from 'utils/common';
|
||||
|
||||
export const variablesToRaw = (variables) => {
|
||||
return variables
|
||||
.filter((v) => v.name && v.name.trim() !== '')
|
||||
.map((v) => {
|
||||
const value = v.value || '';
|
||||
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
|
||||
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `${v.name}="${escapedValue}"`;
|
||||
}
|
||||
return `${v.name}=${value}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export const rawToVariables = (rawContent) => {
|
||||
if (!rawContent || rawContent.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const variables = [];
|
||||
const lines = rawContent.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const equalIndex = trimmedLine.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = trimmedLine.substring(0, equalIndex).trim();
|
||||
let value = trimmedLine.substring(equalIndex + 1);
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
variables.push({
|
||||
uid: uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled: true,
|
||||
secret: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
};
|
||||
|
||||
export const MIN_TABLE_HEIGHT = 35 * 2;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
button.submit {
|
||||
color: white;
|
||||
background-color: var(--color-background-danger) !important;
|
||||
border: inherit !important;
|
||||
|
||||
&:hover {
|
||||
border: inherit !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import Modal from 'components/Modal/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DeleteDotEnvFile = ({ onClose, onConfirm, filename = '.env' }) => {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title={`Delete ${filename} File`}
|
||||
confirmText="Delete"
|
||||
handleConfirm={handleConfirm}
|
||||
handleCancel={onClose}
|
||||
confirmButtonColor="danger"
|
||||
>
|
||||
Are you sure you want to delete <span className="font-medium">{filename}</span> file?
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteDotEnvFile;
|
||||
@@ -1,57 +1,20 @@
|
||||
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { get } from 'lodash';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
|
||||
import { sensitiveFields } from './constants';
|
||||
|
||||
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (prevProps, nextProps) => {
|
||||
const prevUid = prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.uid;
|
||||
return prevUid === nextUid && prevProps.children === nextProps.children;
|
||||
});
|
||||
|
||||
const MIN_H = 35 * 2; // 2 rows worth of height
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const [tableHeight, setTableHeight] = React.useState(MIN_H);
|
||||
|
||||
const environmentsDraft = collection?.environmentsDraft;
|
||||
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
|
||||
|
||||
const handleTotalHeightChanged = React.useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
// Track environment changes for draft restoration
|
||||
const prevEnvUidRef = React.useRef(null);
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// Check for non-secret variables used in sensitive fields
|
||||
const nonSecretSensitiveVarUsageMap = useMemo(() => {
|
||||
const result = {};
|
||||
@@ -97,428 +60,59 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
|
||||
return result;
|
||||
}, [collection, environment]);
|
||||
|
||||
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
|
||||
const hasSensitiveUsage = useCallback((name) => !!nonSecretSensitiveVarUsageMap[name], [nonSecretSensitiveVarUsageMap]);
|
||||
|
||||
// Initial values based only on saved environment variables (not draft)
|
||||
// Draft restoration happens in a separate effect to avoid infinite loops
|
||||
const initialValues = React.useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}, [environment.uid, environment.variables]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues,
|
||||
validationSchema: Yup.array().of(
|
||||
Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
name: Yup.string()
|
||||
.when('$isLastRow', {
|
||||
is: true,
|
||||
then: (schema) => schema.optional(),
|
||||
otherwise: (schema) =>
|
||||
schema
|
||||
.required('Name cannot be empty')
|
||||
.matches(
|
||||
variableNameRegex,
|
||||
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
|
||||
)
|
||||
.trim()
|
||||
}),
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable()
|
||||
})
|
||||
),
|
||||
validate: (values) => {
|
||||
const errors = {};
|
||||
values.forEach((variable, index) => {
|
||||
const isLastRow = index === values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name cannot be empty';
|
||||
} else if (!variableNameRegex.test(variable.name)) {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name
|
||||
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
|
||||
}
|
||||
});
|
||||
return Object.keys(errors).length > 0 ? errors : {};
|
||||
const handleSave = useCallback(
|
||||
(variables) => {
|
||||
return dispatch(saveEnvironment(cloneDeep(variables), environment.uid, collection.uid));
|
||||
},
|
||||
onSubmit: () => {}
|
||||
});
|
||||
[dispatch, environment.uid, collection.uid]
|
||||
);
|
||||
|
||||
// Restore draft values on mount or environment switch
|
||||
useEffect(() => {
|
||||
const isMount = !mountedRef.current;
|
||||
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
|
||||
const handleDraftChange = useCallback(
|
||||
(variables) => {
|
||||
dispatch(
|
||||
setEnvironmentsDraft({
|
||||
collectionUid: collection.uid,
|
||||
environmentUid: environment.uid,
|
||||
variables
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, collection.uid, environment.uid]
|
||||
);
|
||||
|
||||
prevEnvUidRef.current = environment.uid;
|
||||
mountedRef.current = true;
|
||||
const handleDraftClear = useCallback(() => {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}, [dispatch, collection.uid]);
|
||||
|
||||
if ((isMount || envChanged) && hasDraftForThisEnv && environmentsDraft?.variables) {
|
||||
formik.setValues([
|
||||
...environmentsDraft.variables,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, hasDraftForThisEnv, environmentsDraft?.variables]);
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify(environment.variables || []);
|
||||
}, [environment.variables]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
setIsModified(hasActualChanges);
|
||||
}, [formik.values, savedValuesJson, setIsModified]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
|
||||
// Get existing draft for comparison
|
||||
const existingDraftVariables = hasDraftForThisEnv ? environmentsDraft?.variables : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
|
||||
|
||||
if (hasActualChanges) {
|
||||
// Only dispatch if draft values are actually different
|
||||
if (currentValuesJson !== existingDraftJson) {
|
||||
dispatch(setEnvironmentsDraft({
|
||||
collectionUid: collection.uid,
|
||||
environmentUid: environment.uid,
|
||||
variables: currentValues
|
||||
}));
|
||||
}
|
||||
} else if (hasDraftForThisEnv) {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
const renderExtraValueContent = useCallback(
|
||||
(variable) => {
|
||||
if (!variable.secret && hasSensitiveUsage(variable.name)) {
|
||||
return (
|
||||
<SensitiveFieldWarning
|
||||
fieldName={variable.name}
|
||||
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [formik.values, savedValuesJson, environment.uid, collection.uid, dispatch, hasDraftForThisEnv, environmentsDraft?.variables]);
|
||||
|
||||
const ErrorMessage = ({ name, index }) => {
|
||||
const meta = formik.getFieldMeta(name);
|
||||
const id = `error-${name}-${index}`;
|
||||
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const variable = formik.values[index];
|
||||
const isEmptyRow = !variable?.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!meta.error || !meta.touched) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
|
||||
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveVar = useCallback((id) => {
|
||||
const currentValues = formik.values;
|
||||
|
||||
if (!currentValues || currentValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastRow = currentValues[currentValues.length - 1];
|
||||
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
|
||||
|
||||
if (isLastEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
|
||||
|
||||
const hasEmptyLastRow
|
||||
= filteredValues.length > 0
|
||||
&& (!filteredValues[filteredValues.length - 1].name
|
||||
|| filteredValues[filteredValues.length - 1].name.trim() === '');
|
||||
|
||||
const newValues = hasEmptyLastRow
|
||||
? filteredValues
|
||||
: [
|
||||
...filteredValues,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
|
||||
formik.setValues(newValues);
|
||||
}, [formik.values]);
|
||||
|
||||
const handleNameChange = (index, e) => {
|
||||
formik.handleChange(e);
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
|
||||
if (isLastRow) {
|
||||
const newVariable = {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
};
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, newVariable, false);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = (index) => {
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (index, e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValidationErrors = variablesToSave.some((variable) => {
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
if (!variableNameRegex.test(variable.name)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasValidationErrors) {
|
||||
toast.error('Please fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(saveEnvironment(cloneDeep(variablesToSave), environment.uid, collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const originalVars = environment.variables || [];
|
||||
const resetValues = [
|
||||
...originalVars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: resetValues });
|
||||
setIsModified(false);
|
||||
};
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveEvent = () => {
|
||||
handleSaveRef.current();
|
||||
};
|
||||
|
||||
window.addEventListener('environment-save', handleSaveEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('environment-save', handleSaveEvent);
|
||||
};
|
||||
}, []);
|
||||
},
|
||||
[hasSensitiveUsage]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
components={{ TableRow }}
|
||||
data={formik.values}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(index, variable) => variable.uid}
|
||||
itemContent={(index, variable) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${index}.name`}
|
||||
name={`${index}.name`}
|
||||
value={variable.name}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(index, e)}
|
||||
onBlur={() => handleNameBlur(index)}
|
||||
onKeyDown={(e) => handleNameKeyDown(index, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${index}.name`} index={index} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
|
||||
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"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!variable.secret && hasSensitiveUsage(variable.name) && (
|
||||
<SensitiveFieldWarning
|
||||
fieldName={variable.name}
|
||||
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
<EnvironmentVariablesTable
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
draft={hasDraftForThisEnv ? environmentsDraft : null}
|
||||
onDraftChange={handleDraftChange}
|
||||
onDraftClear={handleDraftClear}
|
||||
setIsModified={setIsModified}
|
||||
renderExtraValueContent={renderExtraValueContent}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -94,8 +94,63 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
padding: 5px 32px 5px 32px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.input.placeholder.color};
|
||||
opacity: ${(props) => props.theme.input.placeholder.opacity};
|
||||
}
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const inputRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
@@ -112,6 +117,23 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchIconClick = () => {
|
||||
setIsSearchExpanded(true);
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleSearchBlur = () => {
|
||||
if (searchQuery === '') {
|
||||
setIsSearchExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid))
|
||||
.then(() => {
|
||||
@@ -176,6 +198,38 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
<div className="actions">
|
||||
{isSearchExpanded ? (
|
||||
<div className="search-input-wrapper">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search variables..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
className="search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="clear-search"
|
||||
onClick={handleClearSearch}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Clear search"
|
||||
>
|
||||
<IconX size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleSearchIconClick} title="Search variables">
|
||||
<IconSearch size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleRenameClick} title="Rename">
|
||||
<IconEdit size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -189,7 +243,12 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
<EnvironmentVariables
|
||||
environment={environment}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
searchQuery={debouncedSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -99,12 +99,39 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
.sections-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
overflow-y: auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -281,6 +308,39 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => `${props.theme.colors.text.danger}15`};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-env-file {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 10%;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
svg {
|
||||
opacity: 0.3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import useOnClickOutside from 'hooks/useOnClickOutside';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import CollapsibleSection from 'components/Environments/CollapsibleSection';
|
||||
import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor';
|
||||
import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
addEnvironment,
|
||||
renameEnvironment,
|
||||
selectEnvironment,
|
||||
saveDotEnvVariables,
|
||||
saveDotEnvRaw,
|
||||
createDotEnvFile,
|
||||
deleteDotEnvFile
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
const EnvironmentList = ({
|
||||
environments,
|
||||
@@ -25,7 +40,6 @@ const EnvironmentList = ({
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
@@ -38,10 +52,40 @@ const EnvironmentList = ({
|
||||
|
||||
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
|
||||
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
|
||||
const [environmentsExpanded, setEnvironmentsExpanded] = useState(true);
|
||||
const [dotEnvExpanded, setDotEnvExpanded] = useState(false);
|
||||
const [activeView, setActiveView] = useState('environment');
|
||||
const [isDotEnvModified, setIsDotEnvModified] = useState(false);
|
||||
const [dotEnvViewMode, setDotEnvViewMode] = useState('table');
|
||||
const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null);
|
||||
const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false);
|
||||
const [newDotEnvName, setNewDotEnvName] = useState('.env');
|
||||
const [dotEnvNameError, setDotEnvNameError] = useState('');
|
||||
const dotEnvInputRef = useRef(null);
|
||||
const dotEnvCreateContainerRef = useRef(null);
|
||||
|
||||
const dotEnvFiles = useSelector((state) => {
|
||||
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
|
||||
return coll?.dotEnvFiles || EMPTY_ARRAY;
|
||||
});
|
||||
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
setSelectedDotEnvFile(null);
|
||||
setActiveView('environment');
|
||||
setIsDotEnvModified(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile);
|
||||
if (!selectedDotEnvFile || !fileExists) {
|
||||
setSelectedDotEnvFile(dotEnvFiles[0].filename);
|
||||
}
|
||||
}, [dotEnvFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environments?.length) {
|
||||
setSelectedEnvironment(null);
|
||||
@@ -87,44 +131,34 @@ const EnvironmentList = ({
|
||||
}
|
||||
}, [envUids, environments, prevEnvUids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingEnvUid) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
|
||||
handleCancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [renamingEnvUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreatingInline) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
|
||||
handleCancelCreate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isCreatingInline]);
|
||||
|
||||
const handleEnvironmentClick = (env) => {
|
||||
if (activeView === 'dotenv' && isDotEnvModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
if (!isModified) {
|
||||
setSelectedEnvironment(env);
|
||||
setActiveView('environment');
|
||||
setEnvironmentsExpanded(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDotEnvClick = (filename) => {
|
||||
if (isModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
setSelectedDotEnvFile(filename);
|
||||
setActiveView('dotenv');
|
||||
setDotEnvExpanded(true);
|
||||
};
|
||||
|
||||
const handleEnvironmentDoubleClick = (env) => {
|
||||
setRenamingEnvUid(env.uid);
|
||||
setNewEnvName(env.name);
|
||||
@@ -135,7 +169,7 @@ const EnvironmentList = ({
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleActivateEnvironment = (e, env) => {
|
||||
const handleActivateEnvironment = useCallback((e, env) => {
|
||||
e.stopPropagation();
|
||||
dispatch(selectEnvironment(env.uid, collection.uid))
|
||||
.then(() => {
|
||||
@@ -144,11 +178,7 @@ const EnvironmentList = ({
|
||||
.catch(() => {
|
||||
toast.error('Failed to activate environment');
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
}, [dispatch, collection.uid]);
|
||||
|
||||
const validateEnvironmentName = (name, excludeUid = null) => {
|
||||
if (!name || name.trim() === '') {
|
||||
@@ -171,7 +201,7 @@ const EnvironmentList = ({
|
||||
};
|
||||
|
||||
const handleCreateEnvClick = () => {
|
||||
if (!isModified) {
|
||||
if (!isModified && !isDotEnvModified) {
|
||||
setIsCreatingInline(true);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
@@ -183,11 +213,13 @@ const EnvironmentList = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
const handleCancelCreate = useCallback(() => {
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
|
||||
|
||||
const handleSaveNewEnv = () => {
|
||||
const error = validateEnvironmentName(newEnvName);
|
||||
@@ -254,14 +286,16 @@ const EnvironmentList = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setRenamingEnvUid(null);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid);
|
||||
|
||||
const handleImportClick = () => {
|
||||
if (!isModified) {
|
||||
if (!isModified && !isDotEnvModified) {
|
||||
setOpenImportModal(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
@@ -280,12 +314,197 @@ const EnvironmentList = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDotEnv = (variables) => {
|
||||
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
|
||||
return dispatch(saveDotEnvVariables(collection.uid, variables, selectedDotEnvFile));
|
||||
};
|
||||
|
||||
const handleSaveDotEnvRaw = (content) => {
|
||||
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
|
||||
return dispatch(saveDotEnvRaw(collection.uid, content, selectedDotEnvFile));
|
||||
};
|
||||
|
||||
const handleCreateDotEnvInlineClick = () => {
|
||||
if (isModified || isDotEnvModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
setIsCreatingDotEnvInline(true);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
setTimeout(() => {
|
||||
dotEnvInputRef.current?.focus();
|
||||
const input = dotEnvInputRef.current;
|
||||
if (input) {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleCancelDotEnvCreate = useCallback(() => {
|
||||
setIsCreatingDotEnvInline(false);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline);
|
||||
|
||||
const validateDotEnvName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (!name.startsWith('.env')) {
|
||||
return 'File name must start with .env';
|
||||
}
|
||||
|
||||
const validPattern = /^\.env[a-zA-Z0-9._-]*$/;
|
||||
if (!validPattern.test(name)) {
|
||||
return 'Invalid file name';
|
||||
}
|
||||
|
||||
const exists = dotEnvFiles.some((f) => f.filename === name);
|
||||
if (exists) {
|
||||
return 'File already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSaveNewDotEnv = () => {
|
||||
const error = validateDotEnvName(newDotEnvName);
|
||||
if (error) {
|
||||
setDotEnvNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(createDotEnvFile(collection.uid, newDotEnvName))
|
||||
.then(() => {
|
||||
toast.success(`${newDotEnvName} file created!`);
|
||||
setIsCreatingDotEnvInline(false);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
setSelectedDotEnvFile(newDotEnvName);
|
||||
setActiveView('dotenv');
|
||||
setDotEnvExpanded(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message || 'Failed to create .env file');
|
||||
});
|
||||
};
|
||||
|
||||
const handleDotEnvNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
if (!value.startsWith('.env')) {
|
||||
setNewDotEnvName('.env');
|
||||
} else {
|
||||
setNewDotEnvName(value);
|
||||
}
|
||||
if (dotEnvNameError) {
|
||||
setDotEnvNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDotEnvNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveNewDotEnv();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelDotEnvCreate();
|
||||
} else if (e.key === 'Backspace') {
|
||||
const input = e.target;
|
||||
if (input.selectionStart <= 4 && input.selectionEnd <= 4) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDotEnvFile = (filename) => {
|
||||
dispatch(deleteDotEnvFile(collection.uid, filename))
|
||||
.then(() => {
|
||||
toast.success(`${filename} file deleted!`);
|
||||
setIsDotEnvModified(false);
|
||||
if (selectedDotEnvFile === filename) {
|
||||
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
|
||||
if (remainingFiles.length > 0) {
|
||||
setSelectedDotEnvFile(remainingFiles[0].filename);
|
||||
} else {
|
||||
setActiveView('environment');
|
||||
if (environments?.length) {
|
||||
const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0];
|
||||
setSelectedEnvironment(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message || 'Failed to delete .env file');
|
||||
});
|
||||
};
|
||||
|
||||
const handleDotEnvViewModeChange = (mode) => {
|
||||
setDotEnvViewMode(mode);
|
||||
};
|
||||
|
||||
const filteredEnvironments
|
||||
= environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
|
||||
|
||||
const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile);
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) {
|
||||
return (
|
||||
<DotEnvFileDetails
|
||||
title={selectedDotEnvFile}
|
||||
onDelete={() => handleDeleteDotEnvFile(selectedDotEnvFile)}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
onViewModeChange={handleDotEnvViewModeChange}
|
||||
>
|
||||
<DotEnvFileEditor
|
||||
variables={selectedDotEnvData?.variables || []}
|
||||
onSave={handleSaveDotEnv}
|
||||
onSaveRaw={handleSaveDotEnvRaw}
|
||||
isModified={isDotEnvModified}
|
||||
setIsModified={setIsDotEnvModified}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
collection={collection}
|
||||
/>
|
||||
</DotEnvFileDetails>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEnvironment) {
|
||||
return (
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<Button size="sm" color="secondary" onClick={() => handleCreateEnvClick()}>
|
||||
Create Environment
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" onClick={() => handleImportClick()}>
|
||||
Import Environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && (
|
||||
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />
|
||||
)}
|
||||
@@ -299,42 +518,111 @@ const EnvironmentList = ({
|
||||
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Environments</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<IconUpload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="title">Variables</h2>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
placeholder="Search..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<div className="sections-container">
|
||||
<CollapsibleSection
|
||||
title="Environments"
|
||||
expanded={environmentsExpanded}
|
||||
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={classnames('environment-item', {
|
||||
active: activeView === 'environment' && selectedEnvironment?.uid === env.uid,
|
||||
renaming: renamingEnvUid === env.uid,
|
||||
activated: activeEnvironmentUid === env.uid
|
||||
})}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -342,6 +630,7 @@ const EnvironmentList = ({
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
@@ -350,7 +639,7 @@ const EnvironmentList = ({
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
@@ -358,7 +647,7 @@ const EnvironmentList = ({
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
@@ -366,76 +655,94 @@ const EnvironmentList = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
|
||||
|
||||
{filteredEnvironments.length === 0 && !isCreatingInline && (
|
||||
<div className="no-env-file">
|
||||
<span>No environments</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
actions={(
|
||||
<button
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="environments-list">
|
||||
{dotEnvFiles.map((file) => (
|
||||
<div
|
||||
key={file.filename}
|
||||
className={classnames('environment-item', {
|
||||
active: activeView === 'dotenv' && selectedDotEnvFile === file.filename
|
||||
})}
|
||||
onClick={() => handleDotEnvClick(file.filename)}
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="environment-name">{file.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingDotEnvInline && (
|
||||
<div className="environment-item creating" ref={dotEnvCreateContainerRef}>
|
||||
<input
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewDotEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Create"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelDotEnvCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dotEnvNameError && isCreatingDotEnvInline && <div className="env-error">{dotEnvNameError}</div>}
|
||||
|
||||
{dotEnvFiles.length === 0 && !isCreatingDotEnvInline && (
|
||||
<div className="no-env-file">
|
||||
<span>No .env files</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const DefaultTab = ({ setTab }) => (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
|
||||
Create Environment
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
|
||||
Import Environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EnvironmentSettings = ({ collection }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
@@ -30,23 +11,8 @@ const EnvironmentSettings = ({ collection }) => {
|
||||
if (!environments.length) return null;
|
||||
return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0];
|
||||
});
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
if (!environments || !environments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<EnvironmentList
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments';
|
||||
|
||||
const GlobalEnvironmentSettings = () => {
|
||||
return <WorkspaceEnvironments />;
|
||||
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
|
||||
const workspace = useSelector((state) =>
|
||||
state.workspaces.workspaces.find((w) => w.uid === activeWorkspaceUid)
|
||||
);
|
||||
|
||||
return <WorkspaceEnvironments workspace={workspace} />;
|
||||
};
|
||||
|
||||
export default GlobalEnvironmentSettings;
|
||||
|
||||
@@ -60,7 +60,7 @@ const Headers = ({ collection, folder }) => {
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -68,7 +68,7 @@ const Headers = ({ collection, folder }) => {
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
placeholder={!value ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -76,7 +76,7 @@ const Headers = ({ collection, folder }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -85,7 +85,7 @@ const Headers = ({ collection, folder }) => {
|
||||
collection={collection}
|
||||
item={folder}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -59,7 +59,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.bruno-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.submit {
|
||||
margin-top: 1rem;
|
||||
@@ -25,8 +26,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.no-features-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--color-gray-500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -93,12 +93,9 @@ const Beta = ({ close }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="section-header">Beta Features</div>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-2">
|
||||
<IconFlask size={20} className="mr-2 text-orange-500" />
|
||||
<h2 className="text-lg font-medium">Beta Features</h2>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4 text-wrap">
|
||||
Beta features are experimental previews that may change before full release. Try them and share feedback.
|
||||
</p>
|
||||
|
||||
@@ -3,9 +3,12 @@ import Font from './Font/index';
|
||||
|
||||
const Display = ({ close }) => {
|
||||
return (
|
||||
<div className="flex flex-col my-2 gap-10 w-full">
|
||||
<div className="w-fit flex flex-col gap-2">
|
||||
<Font close={close} />
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="section-header">Display</div>
|
||||
<div className="flex flex-col mb-2 gap-10 w-full">
|
||||
<div className="w-fit flex flex-col gap-2">
|
||||
<Font close={close} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.text-link {
|
||||
@@ -18,6 +23,10 @@ const StyledWrapper = styled.div`
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.default-collection-location-input {
|
||||
max-width: 28rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -174,8 +174,9 @@ const General = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">General Settings</div>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="flex items-center my-2">
|
||||
<div className="flex items-center mb-2">
|
||||
<input
|
||||
id="sslVerification"
|
||||
type="checkbox"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -8,6 +8,7 @@ const Keybindings = ({ close }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">Keybindings</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.settings-label {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -37,6 +42,7 @@ const StyledWrapper = styled.div`
|
||||
.system-proxy-error-container {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
border: 1px solid ${(props) => props.theme.status.danger.border};
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.system-proxy-error-text {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import { getSystemProxyVariables } from 'providers/ReduxStore/slices/app';
|
||||
import { IconLoader2, IconRefresh } from '@tabler/icons';
|
||||
import { getSystemProxyVariables, refreshSystemProxy } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from '../StyledWrapper';
|
||||
|
||||
const SystemProxy = () => {
|
||||
@@ -11,21 +11,32 @@ const SystemProxy = () => {
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getSystemProxyVariables())
|
||||
const fetchProxy = (forceRefresh = false) => {
|
||||
setIsFetching(true);
|
||||
setError(null);
|
||||
const action = forceRefresh ? refreshSystemProxy : getSystemProxyVariables;
|
||||
dispatch(action())
|
||||
.then(() => setError(null))
|
||||
.catch((err) => setError(err.message || String(err)))
|
||||
.finally(() => setIsFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProxy(false);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchProxy(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="mb-3 text-muted system-proxy-settings space-y-4">
|
||||
<div className="flex items-start justify-start flex-col gap-2 mt-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-xs system-proxy-title">
|
||||
System Proxy {isFetching ? <IconLoader2 className="animate-spin ml-1" size={18} strokeWidth={1.5} /> : null}
|
||||
<h2 className="text-xs system-proxy-title flex flex-row">
|
||||
System Proxy {isFetching ? <IconLoader2 className="animate-spin ml-1" size={16} strokeWidth={1.5} /> : null}
|
||||
</h2>
|
||||
<small className="system-proxy-description">
|
||||
Below values are sourced from your system proxy settings.
|
||||
@@ -75,6 +86,13 @@ const SystemProxy = () => {
|
||||
<div className="system-proxy-value">{no_proxy || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
|
||||
Refresh
|
||||
</span>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -113,6 +113,7 @@ const ProxySettings = ({ close }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="section-header">Proxy Settings</div>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="mb-3 flex items-center mt-2">
|
||||
<label className="settings-label" htmlFor="protocol">
|
||||
|
||||
@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
margin: 6px 0 8px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
.rows {
|
||||
svg {
|
||||
|
||||
@@ -8,8 +8,9 @@ const Support = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="section-header">Support</div>
|
||||
<div className="rows">
|
||||
<div className="mt-2">
|
||||
<div className="mb-2">
|
||||
<a href="https://docs.usebruno.com" target="_blank" className="flex items-end">
|
||||
<IconBook size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.appearance-container {
|
||||
padding: 8px 0 16px 0;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.theme-mode-option {
|
||||
|
||||
@@ -125,7 +125,7 @@ const Assertions = ({ item, collection }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => {
|
||||
render: ({ row, value, onChange }) => {
|
||||
const { operator, value: assertionValue } = parseAssertionOperator(value);
|
||||
|
||||
if (isUnaryOperator(operator)) {
|
||||
@@ -141,7 +141,7 @@ const Assertions = ({ item, collection }) => {
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -57,7 +57,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
allowNewlines={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
</div>
|
||||
{!hasTextValue && !isLastEmptyRow && (
|
||||
@@ -178,11 +178,11 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
name: 'Content-Type',
|
||||
placeholder: 'Auto',
|
||||
width: '20%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
onSave={onSave}
|
||||
theme={storedTheme}
|
||||
placeholder={isLastEmptyRow ? 'Auto' : ''}
|
||||
placeholder={!value ? 'Auto' : ''}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
|
||||
@@ -70,7 +70,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -80,7 +80,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
collection={collection}
|
||||
item={item}
|
||||
variablesAutocomplete={true}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -76,7 +76,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
placeholder={!value ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -84,7 +84,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -94,7 +94,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
autocomplete={MimeTypes}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -70,7 +70,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,12 +130,12 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
|
||||
placeholder: 'Auto',
|
||||
width: '30%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
className="flex items-center justify-center"
|
||||
onSave={() => {}}
|
||||
theme={storedTheme}
|
||||
placeholder={isLastEmptyRow ? 'Auto' : ''}
|
||||
placeholder={!value ? 'Auto' : ''}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
onRun={() => {}}
|
||||
|
||||
@@ -58,7 +58,7 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi
|
||||
placeholder: 'Value',
|
||||
width: '60%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -68,7 +68,7 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
|
||||
placeholder: 'Key',
|
||||
width: '40%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
readOnly={!editMode}
|
||||
@@ -78,7 +78,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
|
||||
autocomplete={headerAutoCompleteList}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? 'Key' : ''}
|
||||
placeholder={!value ? 'Key' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -88,7 +88,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
|
||||
placeholder: 'Value',
|
||||
width: '60%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
readOnly={!editMode}
|
||||
@@ -100,7 +100,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
|
||||
allowNewlines={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
collection={collection}
|
||||
item={item}
|
||||
readOnly={!editMode}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
</div>
|
||||
{!hasTextValue && !isLastEmptyRow && (
|
||||
@@ -228,11 +228,11 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
placeholder: 'Auto',
|
||||
width: '30%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
onSave={() => {}}
|
||||
theme={storedTheme}
|
||||
placeholder={isLastEmptyRow ? 'Auto' : ''}
|
||||
placeholder={!value ? 'Auto' : ''}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
onRun={() => {}}
|
||||
|
||||
@@ -105,7 +105,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
|
||||
placeholder: 'Name',
|
||||
width: '40%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -115,7 +115,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
|
||||
collection={collection}
|
||||
variablesAutocomplete={true}
|
||||
readOnly={!editMode}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
placeholder={!value ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -125,7 +125,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
|
||||
placeholder: 'Value',
|
||||
width: '60%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -135,7 +135,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
|
||||
collection={collection}
|
||||
variablesAutocomplete={true}
|
||||
readOnly={!editMode}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
|
||||
placeholder: 'Value',
|
||||
width: '60%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -164,7 +164,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
|
||||
collection={collection}
|
||||
variablesAutocomplete={true}
|
||||
readOnly={!editMode}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
|
||||
placeholder: 'Key',
|
||||
width: '40%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -134,7 +134,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
readOnly={!editMode}
|
||||
placeholder={isLastEmptyRow ? 'Key' : ''}
|
||||
placeholder={!value ? 'Key' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -144,7 +144,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
|
||||
placeholder: 'Value',
|
||||
width: '60%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -156,7 +156,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
|
||||
collection={collection}
|
||||
item={item}
|
||||
readOnly={!editMode}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,49 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
|
||||
const copyButtonRef = useRef(null);
|
||||
const layoutToggleButtonRef = useRef(null);
|
||||
|
||||
/**
|
||||
* GQL response actions missing with Save response - because their is schema validation missing for saving GQL response will undo once example
|
||||
* scehem is updated
|
||||
*/
|
||||
const gqlMenuItems = [
|
||||
{
|
||||
id: 'copy-response',
|
||||
label: 'Copy response',
|
||||
leftSection: IconCopy,
|
||||
get disabled() {
|
||||
return copyButtonRef.current?.isDisabled ?? false;
|
||||
},
|
||||
onClick: () => copyButtonRef.current?.click()
|
||||
},
|
||||
{
|
||||
id: 'download-response',
|
||||
label: 'Download response',
|
||||
leftSection: IconDownload,
|
||||
get disabled() {
|
||||
return downloadButtonRef.current?.isDisabled ?? false;
|
||||
},
|
||||
onClick: () => downloadButtonRef.current?.click()
|
||||
},
|
||||
{
|
||||
id: 'clear-response',
|
||||
label: 'Clear response',
|
||||
leftSection: IconEraser,
|
||||
get disabled() {
|
||||
return clearButtonRef.current?.isDisabled ?? false;
|
||||
},
|
||||
onClick: () => clearButtonRef.current?.click()
|
||||
},
|
||||
{
|
||||
id: 'change-layout',
|
||||
label: 'Change layout',
|
||||
leftSection: orientation === 'vertical' ? IconLayoutColumns : IconLayoutRows,
|
||||
get disabled() {
|
||||
return layoutToggleButtonRef.current?.isDisabled ?? false;
|
||||
},
|
||||
onClick: () => layoutToggleButtonRef.current?.click()
|
||||
}
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'copy-response',
|
||||
@@ -95,7 +138,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
|
||||
}
|
||||
];
|
||||
|
||||
if (item.type !== 'http-request') {
|
||||
if (!['http-request', 'graphql-request'].includes(item.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -103,7 +146,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
|
||||
<StyledWrapper className="response-pane-actions-wrapper">
|
||||
<div className="actions-dropdown">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
items={item.type !== 'graphql-request' ? menuItems : gqlMenuItems}
|
||||
placement="bottom-end"
|
||||
data-testid="response-actions-menu"
|
||||
>
|
||||
@@ -119,7 +162,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
/>
|
||||
<ResponseBookmark ref={bookmarkButtonRef} item={item} collection={collection} responseSize={responseSize} />
|
||||
{item.type !== 'graphql-request' && <ResponseBookmark ref={bookmarkButtonRef} item={item} collection={collection} responseSize={responseSize} />}
|
||||
<ResponseDownload ref={downloadButtonRef} item={item} />
|
||||
<ResponseClear ref={clearButtonRef} item={item} collection={collection} />
|
||||
<ResponseLayoutToggle ref={layoutToggleButtonRef} />
|
||||
|
||||
@@ -14,6 +14,7 @@ import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import { itemSchema } from '@usebruno/schema';
|
||||
|
||||
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
|
||||
@@ -115,7 +116,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
|
||||
await itemSchema.validate(transformedItem);
|
||||
|
||||
const format = collection.format || 'bru';
|
||||
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
|
||||
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
|
||||
|
||||
await ipcRenderer.invoke('renderer:save-transient-request', {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.tabs {
|
||||
@@ -28,44 +27,157 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.beta-badge-corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.15)};
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
border-top-right-radius: ${(props) => props.theme.border.radius.base};
|
||||
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
|
||||
letter-spacing: 0.05em;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.share-button {
|
||||
display: flex;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
padding: 10px;
|
||||
border: 1px solid ${(props) => props.theme.border.border0};
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&.no-padding {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.note-warning {
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.06)};
|
||||
}
|
||||
|
||||
.opencollection-link {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.bruno-format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.format-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
padding: 1rem;
|
||||
border: 2px solid ${(props) => props.theme.border.border0};
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
min-height: 180px;
|
||||
|
||||
&:hover:not(.selected) {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.recommended-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.25rem;
|
||||
background-color: ${(props) => props.theme.colors.text.warning};
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
|
||||
.checkmark {
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.best-for {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.other-format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.other-format-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid ${(props) => props.theme.border.border0};
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover:not(.selected) {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
.format-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.format-info {
|
||||
.format-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.format-description {
|
||||
font-size: 0.75rem;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border0};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import { IconUpload, IconLoader2, IconAlertTriangle } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import { IconCheck, IconAlertTriangle, IconFileExport } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Bruno from 'components/Bruno';
|
||||
import OpenCollectionIcon from 'components/Icons/OpenCollectionIcon';
|
||||
import exportBrunoCollection from 'utils/collections/export';
|
||||
import exportPostmanCollection from 'utils/exporters/postman-collection';
|
||||
import exportOpenCollection from 'utils/exporters/opencollection';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
|
||||
import { useApp } from 'providers/App';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const EXPORT_FORMATS = {
|
||||
ZIP: 'zip',
|
||||
YAML: 'yaml',
|
||||
POSTMAN: 'postman'
|
||||
};
|
||||
|
||||
const ShareCollection = ({ onClose, collectionUid }) => {
|
||||
const { version } = useApp();
|
||||
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const [selectedFormat, setSelectedFormat] = useState(EXPORT_FORMATS.ZIP);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const hasNonExportableRequestTypes = useMemo(() => {
|
||||
let types = new Set();
|
||||
@@ -40,110 +45,157 @@ const ShareCollection = ({ onClose, collectionUid }) => {
|
||||
};
|
||||
}, [collection]);
|
||||
|
||||
const handleExportBrunoCollection = () => {
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
|
||||
onClose();
|
||||
const handleExportZip = async () => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
|
||||
if (result.success) {
|
||||
toast.success('Collection exported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to export collection: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPostmanCollection = () => {
|
||||
const handleExportYaml = () => {
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
|
||||
};
|
||||
|
||||
const handleExportPostman = () => {
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
exportPostmanCollection(collectionCopy);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleExportOpenCollection = () => {
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
|
||||
onClose();
|
||||
const handleProceed = async () => {
|
||||
if (isCollectionLoading || isExporting) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
switch (selectedFormat) {
|
||||
case EXPORT_FORMATS.ZIP:
|
||||
await handleExportZip();
|
||||
break;
|
||||
case EXPORT_FORMATS.YAML:
|
||||
handleExportYaml();
|
||||
break;
|
||||
case EXPORT_FORMATS.POSTMAN:
|
||||
handleExportPostman();
|
||||
break;
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = isCollectionLoading || isExporting;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Share Collection"
|
||||
confirmText="Close"
|
||||
handleConfirm={onClose}
|
||||
handleCancel={onClose}
|
||||
hideCancel
|
||||
>
|
||||
<StyledWrapper className="flex flex-col h-full w-[500px]">
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={`share-button ${
|
||||
isCollectionLoading
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
|
||||
<Modal size="lg" title="Share Collection" handleCancel={onClose} hideFooter>
|
||||
<StyledWrapper className="flex flex-col">
|
||||
<p className="text-sm mb-4">
|
||||
Bruno uses{' '}
|
||||
<a
|
||||
href="https://opencollection.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opencollection-link"
|
||||
>
|
||||
<div className="mr-3 p-1 rounded-full">
|
||||
{isCollectionLoading ? <IconLoader2 size={28} className="animate-spin" /> : <Bruno width={28} />}
|
||||
OpenCollection
|
||||
</a>
|
||||
{' '}- An open format for API collections
|
||||
</p>
|
||||
|
||||
{/* Bruno Format Section */}
|
||||
<div className="section-title">Bruno Format</div>
|
||||
<div className="bruno-format-grid mb-6">
|
||||
{/* ZIP Option */}
|
||||
<div
|
||||
className={`format-card ${selectedFormat === EXPORT_FORMATS.ZIP ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.ZIP)}
|
||||
>
|
||||
<div className="card-header">
|
||||
<span className="card-title">Bruno Collection (ZIP)</span>
|
||||
<span className="recommended-badge">Recommended</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Bruno Collection</div>
|
||||
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}</div>
|
||||
<p className="card-description">OpenCollection format organized as folders and files</p>
|
||||
<div className="feature-list">
|
||||
<div className="feature-item">
|
||||
<IconCheck size={14} className="checkmark" />
|
||||
<span>Folder structure with individual .yml files</span>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<IconCheck size={14} className="checkmark" />
|
||||
<span>Collaborate with your team via pull requests</span>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<IconCheck size={14} className="checkmark" />
|
||||
<span>Extract and open directly in Bruno</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="best-for">Best for: Team collaboration, version control, publishing</p>
|
||||
</div>
|
||||
|
||||
{/* Single File YAML Option */}
|
||||
<div
|
||||
className={`share-button relative ${
|
||||
isCollectionLoading
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
onClick={isCollectionLoading ? undefined : handleExportOpenCollection}
|
||||
className={`format-card ${selectedFormat === EXPORT_FORMATS.YAML ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.YAML)}
|
||||
>
|
||||
<span className="beta-badge-corner">Beta</span>
|
||||
<div className="mr-3 p-1 rounded-full">
|
||||
{isCollectionLoading ? (
|
||||
<IconLoader2 size={28} className="animate-spin" />
|
||||
) : (
|
||||
<OpenCollectionIcon size={28} />
|
||||
)}
|
||||
<div className="card-header">
|
||||
<span className="card-title">Single File (YAML)</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">OpenCollection</div>
|
||||
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in OpenCollection format'}</div>
|
||||
<p className="card-description">OpenCollection format bundled into one .yml file</p>
|
||||
<div className="feature-list">
|
||||
<div className="feature-item">
|
||||
<IconCheck size={14} className="checkmark" />
|
||||
<span>Everything in a single YAML file</span>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<IconCheck size={14} className="checkmark" />
|
||||
<span>Paste in a gist or attach to an issue</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="best-for">Best for: Quick sharing as a single file</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Other Format</div>
|
||||
<div className="other-format-grid">
|
||||
<div
|
||||
className={`flex !flex-col share-button no-padding ${
|
||||
isCollectionLoading
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
|
||||
className={`other-format-card ${selectedFormat === EXPORT_FORMATS.POSTMAN ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.POSTMAN)}
|
||||
>
|
||||
{hasNonExportableRequestTypes.has && (
|
||||
<div className="px-3 py-2 w-full flex items-center note-warning">
|
||||
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" />
|
||||
<span>
|
||||
Note:
|
||||
{hasNonExportableRequestTypes.types.join(', ')}
|
||||
{' '}
|
||||
requests in this collection will not be exported
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center p-3 w-full">
|
||||
<div className="mr-3 p-1 rounded-full">
|
||||
{isCollectionLoading ? (
|
||||
<IconLoader2 size={28} className="animate-spin" />
|
||||
) : (
|
||||
<IconUpload size={28} strokeWidth={1} className="" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Postman Collection</div>
|
||||
<div className="text-xs">
|
||||
{isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="format-icon">
|
||||
<IconFileExport size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="format-info">
|
||||
<div className="format-name">Postman</div>
|
||||
<div className="format-description">Export for Postman</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFormat === EXPORT_FORMATS.POSTMAN && hasNonExportableRequestTypes.has && (
|
||||
<div className="flex items-center mt-4 p-3 rounded" style={{ backgroundColor: 'rgba(251, 191, 36, 0.1)' }}>
|
||||
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" style={{ color: '#f59e0b' }} />
|
||||
<span className="text-sm" style={{ color: '#f59e0b' }}>
|
||||
Note: {hasNonExportableRequestTypes.types.join(', ')} requests in this collection will not be exported
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-footer">
|
||||
<Button
|
||||
onClick={handleProceed}
|
||||
disabled={isDisabled}
|
||||
loading={isExporting}
|
||||
>
|
||||
{isExporting ? 'Exporting...' : 'Proceed'}
|
||||
</Button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ const ExampleItem = ({ example, item, collection }) => {
|
||||
// Check if this example is the active tab
|
||||
const activeTabUid = useSelector((state) => state.tabs?.activeTabUid);
|
||||
const isExampleActive = activeTabUid === example.uid;
|
||||
const [editName, setEditName] = useState(example.name);
|
||||
const [editName, setEditName] = useState(example.name || '');
|
||||
const [showRenameModal, setShowRenameModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
|
||||
@@ -230,7 +230,7 @@ const ExampleItem = ({ example, item, collection }) => {
|
||||
handleConfirm={() => handleRenameConfirm(editName)}
|
||||
confirmText="Rename"
|
||||
cancelText="Cancel"
|
||||
confirmDisabled={!editName.trim()}
|
||||
confirmDisabled={!editName || !editName.trim()}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="renameExampleName" className="block font-medium">
|
||||
|
||||
@@ -535,7 +535,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const handleCopyItem = () => {
|
||||
dispatch(copyRequest(item));
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied to clipboard`);
|
||||
toast.success(`${itemType} copied`);
|
||||
};
|
||||
|
||||
const handlePasteItem = () => {
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.beta-badge {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.15)};
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
}
|
||||
|
||||
.discussion-link {
|
||||
margin-left: 0.5rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.textLink};
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
.advanced-options {
|
||||
.caret {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
fill: ${(props) => props.theme.textLink};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
@@ -8,11 +8,12 @@ import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import PathDisplay from 'components/PathDisplay/index';
|
||||
import { useState } from 'react';
|
||||
import { IconArrowBackUp, IconEdit, IconExternalLink } from '@tabler/icons';
|
||||
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
|
||||
import Help from 'components/Help';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { multiLineMsg } from 'utils/common';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import get from 'lodash/get';
|
||||
import Button from 'ui/Button';
|
||||
@@ -23,7 +24,11 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
|
||||
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
|
||||
const workspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid);
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const [showFileFormat, setShowFileFormat] = useState(false);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
|
||||
@@ -35,7 +40,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
|
||||
collectionName: '',
|
||||
collectionFolderName: '',
|
||||
collectionLocation: defaultLocation || '',
|
||||
format: 'bru'
|
||||
format: DEFAULT_COLLECTION_FORMAT
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
collectionName: Yup.string()
|
||||
@@ -86,6 +91,20 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const AdvancedOptions = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
|
||||
<button
|
||||
className="btn-advanced"
|
||||
type="button"
|
||||
>
|
||||
Options
|
||||
</button>
|
||||
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
@@ -209,62 +228,61 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="format" className="flex items-center font-medium">
|
||||
File Format
|
||||
<Help width="300">
|
||||
<p>
|
||||
Choose the file format for storing requests in this collection.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
{formik.values.format === 'yml' && (
|
||||
<>
|
||||
<span className="beta-badge">Beta</span>
|
||||
<a
|
||||
href="#"
|
||||
className="discussion-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.open('https://github.com/usebruno/bruno/discussions/6634', '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
Join the discussion
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={formik.values.format}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
{formik.touched.format && formik.errors.format ? (
|
||||
<div className="text-red-500">{formik.errors.format}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showFileFormat && (
|
||||
<div className="mt-4">
|
||||
<label htmlFor="format" className="flex items-center font-medium">
|
||||
File Format
|
||||
<Help width="300">
|
||||
<p>
|
||||
Choose the file format for storing requests in this collection.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={formik.values.format}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
{formik.touched.format && formik.errors.format ? (
|
||||
<div className="text-red-500">{formik.errors.format}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end items-center mt-8 bruno-modal-footer">
|
||||
<span className="mr-2">
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose}>
|
||||
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
|
||||
<div className="flex advanced-options">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
key="show-file-format"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
setShowFileFormat(!showFileFormat);
|
||||
}}
|
||||
>
|
||||
{showFileFormat ? 'Hide File Format' : 'Show File Format'}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -16,6 +16,7 @@ import Modal from 'components/Modal';
|
||||
import Help from 'components/Help';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
|
||||
// Extract collection name from raw data
|
||||
const getCollectionName = (format, rawData) => {
|
||||
@@ -92,7 +93,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [groupingType, setGroupingType] = useState('tags');
|
||||
const [collectionFormat, setCollectionFormat] = useState('bru');
|
||||
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
|
||||
const dropdownTippyRef = useRef();
|
||||
const isOpenApi = format === 'openapi';
|
||||
|
||||
|
||||
@@ -1,456 +1,54 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useRef, useMemo } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
saveGlobalEnvironment,
|
||||
setGlobalEnvironmentDraft,
|
||||
clearGlobalEnvironmentDraft
|
||||
} from 'providers/ReduxStore/slices/global-environments';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
import Button from 'ui/Button';
|
||||
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
|
||||
|
||||
const MIN_H = 35 * 2;
|
||||
|
||||
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (prevProps, nextProps) => {
|
||||
const prevUid = prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.uid;
|
||||
return prevUid === nextUid && prevProps.children === nextProps.children;
|
||||
});
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid, globalEnvironmentDraft } = useSelector(
|
||||
(state) => state.globalEnvironments
|
||||
);
|
||||
const { globalEnvironmentDraft } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid;
|
||||
|
||||
const [tableHeight, setTableHeight] = React.useState(MIN_H);
|
||||
|
||||
const handleTotalHeightChanged = React.useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
// Track environment changes for draft restoration
|
||||
const prevEnvUidRef = React.useRef(null);
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// Initial values based only on saved environment variables (not draft)
|
||||
// Draft restoration happens in a separate effect to avoid infinite loops
|
||||
const initialValues = React.useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}, [environment.uid, environment.variables]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues,
|
||||
validationSchema: Yup.array().of(Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
name: Yup.string()
|
||||
.when('$isLastRow', {
|
||||
is: true,
|
||||
then: (schema) => schema.optional(),
|
||||
otherwise: (schema) => schema
|
||||
.required('Name cannot be empty')
|
||||
.matches(variableNameRegex,
|
||||
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.')
|
||||
.trim()
|
||||
}),
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable()
|
||||
})),
|
||||
validate: (values) => {
|
||||
const errors = {};
|
||||
values.forEach((variable, index) => {
|
||||
const isLastRow = index === values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name cannot be empty';
|
||||
} else if (!variableNameRegex.test(variable.name)) {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
|
||||
}
|
||||
});
|
||||
return Object.keys(errors).length > 0 ? errors : {};
|
||||
const handleSave = useCallback(
|
||||
(variables) => {
|
||||
return dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variables) }));
|
||||
},
|
||||
onSubmit: () => {}
|
||||
});
|
||||
[dispatch, environment.uid]
|
||||
);
|
||||
|
||||
// Restore draft values on mount or environment switch
|
||||
React.useEffect(() => {
|
||||
const isMount = !mountedRef.current;
|
||||
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
|
||||
|
||||
prevEnvUidRef.current = environment.uid;
|
||||
mountedRef.current = true;
|
||||
|
||||
if ((isMount || envChanged) && hasDraftForThisEnv && globalEnvironmentDraft?.variables) {
|
||||
formik.setValues([
|
||||
...globalEnvironmentDraft.variables,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, hasDraftForThisEnv, globalEnvironmentDraft?.variables]);
|
||||
|
||||
// Sync draft state to Redux
|
||||
React.useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const savedValuesJson = JSON.stringify(savedValues);
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
|
||||
setIsModified(hasActualChanges);
|
||||
|
||||
// Get existing draft for comparison
|
||||
const existingDraftVariables = hasDraftForThisEnv ? globalEnvironmentDraft?.variables : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
|
||||
|
||||
if (hasActualChanges) {
|
||||
// Only dispatch if draft values are actually different
|
||||
if (currentValuesJson !== existingDraftJson) {
|
||||
dispatch(setGlobalEnvironmentDraft({
|
||||
const handleDraftChange = useCallback(
|
||||
(variables) => {
|
||||
dispatch(
|
||||
setGlobalEnvironmentDraft({
|
||||
environmentUid: environment.uid,
|
||||
variables: currentValues
|
||||
}));
|
||||
}
|
||||
} else if (hasDraftForThisEnv) {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
}
|
||||
}, [formik.values, environment.variables, environment.uid, setIsModified, dispatch, hasDraftForThisEnv, globalEnvironmentDraft?.variables]);
|
||||
variables
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, environment.uid]
|
||||
);
|
||||
|
||||
const ErrorMessage = ({ name, index }) => {
|
||||
const meta = formik.getFieldMeta(name);
|
||||
const id = `error-${name}-${index}`;
|
||||
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const variable = formik.values[index];
|
||||
const isEmptyRow = !variable?.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!meta.error || !meta.touched) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
|
||||
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveVar = useCallback((id) => {
|
||||
const currentValues = formik.values;
|
||||
|
||||
if (!currentValues || currentValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastRow = currentValues[currentValues.length - 1];
|
||||
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
|
||||
|
||||
if (isLastEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
|
||||
|
||||
const hasEmptyLastRow = filteredValues.length > 0
|
||||
&& (!filteredValues[filteredValues.length - 1].name
|
||||
|| filteredValues[filteredValues.length - 1].name.trim() === '');
|
||||
|
||||
const newValues = hasEmptyLastRow
|
||||
? filteredValues
|
||||
: [
|
||||
...filteredValues,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
|
||||
formik.setValues(newValues);
|
||||
}, [formik.values]);
|
||||
|
||||
const handleNameChange = (index, e) => {
|
||||
formik.handleChange(e);
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
|
||||
if (isLastRow) {
|
||||
const newVariable = {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
};
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, newVariable, false);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = (index) => {
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (index, e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
formik.setFieldTouched(`${index}.name`, true, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValidationErrors = variablesToSave.some((variable) => {
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
if (!variableNameRegex.test(variable.name)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasValidationErrors) {
|
||||
toast.error('Please fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variablesToSave) }))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const originalVars = environment.variables || [];
|
||||
const resetValues = [
|
||||
...originalVars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: resetValues });
|
||||
setIsModified(false);
|
||||
};
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleSaveEvent = () => {
|
||||
handleSaveRef.current();
|
||||
};
|
||||
|
||||
window.addEventListener('environment-save', handleSaveEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('environment-save', handleSaveEvent);
|
||||
};
|
||||
}, []);
|
||||
const handleDraftClear = useCallback(() => {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
data={formik.values}
|
||||
fixedItemHeight={35}
|
||||
components={{ TableRow }}
|
||||
computeItemKey={(index, variable) => variable.uid}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
itemContent={(index, variable) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${index}.name`}
|
||||
name={`${index}.name`}
|
||||
value={variable.name}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(index, e)}
|
||||
onBlur={() => handleNameBlur(index)}
|
||||
onKeyDown={(e) => handleNameKeyDown(index, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${index}.name`} index={index} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
|
||||
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"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="button-container mt-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" onClick={handleSave} data-testid="save-env">
|
||||
Save
|
||||
</Button>
|
||||
<Button type="reset" size="sm" color="secondary" variant="ghost" onClick={handleReset} data-testid="reset-env">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
<EnvironmentVariablesTable
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
draft={hasDraftForThisEnv ? globalEnvironmentDraft : null}
|
||||
onDraftChange={handleDraftChange}
|
||||
onDraftClear={handleDraftClear}
|
||||
setIsModified={setIsModified}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -94,8 +94,63 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
padding: 5px 32px 5px 32px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.input.placeholder.color};
|
||||
opacity: ${(props) => props.theme.input.placeholder.opacity};
|
||||
}
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const inputRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
@@ -111,6 +116,23 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchIconClick = () => {
|
||||
setIsSearchExpanded(true);
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleSearchBlur = () => {
|
||||
if (searchQuery === '') {
|
||||
setIsSearchExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateGlobalEnvironmentColor(environment.uid, color))
|
||||
.then(() => {
|
||||
@@ -178,6 +200,38 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
<div className="actions">
|
||||
{isSearchExpanded ? (
|
||||
<div className="search-input-wrapper">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search variables..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
className="search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="clear-search"
|
||||
onClick={handleClearSearch}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Clear search"
|
||||
>
|
||||
<IconX size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleSearchIconClick} title="Search variables">
|
||||
<IconSearch size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleRenameClick} title="Rename">
|
||||
<IconEdit size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -191,7 +245,12 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
<EnvironmentVariables
|
||||
environment={environment}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
searchQuery={debouncedSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -99,12 +99,39 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
.sections-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
overflow-y: auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -228,46 +255,46 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
|
||||
}
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +308,39 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => `${props.theme.colors.text.danger}15`};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-env-file {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 10%;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
svg {
|
||||
opacity: 0.3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import useOnClickOutside from 'hooks/useOnClickOutside';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import CollapsibleSection from 'components/Environments/CollapsibleSection';
|
||||
import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor';
|
||||
import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import {
|
||||
saveWorkspaceDotEnvVariables,
|
||||
saveWorkspaceDotEnvRaw,
|
||||
createWorkspaceDotEnvFile,
|
||||
deleteWorkspaceDotEnvFile
|
||||
} from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
const EnvironmentList = ({
|
||||
environments,
|
||||
activeEnvironmentUid,
|
||||
selectedEnvironment,
|
||||
setSelectedEnvironment,
|
||||
isModified,
|
||||
setIsModified,
|
||||
collection,
|
||||
workspace,
|
||||
setShowExportModal
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
@@ -30,10 +52,38 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
|
||||
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
|
||||
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
|
||||
const [environmentsExpanded, setEnvironmentsExpanded] = useState(true);
|
||||
const [dotEnvExpanded, setDotEnvExpanded] = useState(false);
|
||||
const [activeView, setActiveView] = useState('environment');
|
||||
const [isDotEnvModified, setIsDotEnvModified] = useState(false);
|
||||
const [dotEnvViewMode, setDotEnvViewMode] = useState('table');
|
||||
const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null);
|
||||
const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false);
|
||||
const [newDotEnvName, setNewDotEnvName] = useState('.env');
|
||||
const [dotEnvNameError, setDotEnvNameError] = useState('');
|
||||
const dotEnvInputRef = useRef(null);
|
||||
const dotEnvCreateContainerRef = useRef(null);
|
||||
|
||||
const dotEnvFiles = useSelector((state) => {
|
||||
const ws = state.workspaces.workspaces.find((w) => w.uid === workspace?.uid);
|
||||
return ws?.dotEnvFiles || EMPTY_ARRAY;
|
||||
});
|
||||
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
setSelectedDotEnvFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile);
|
||||
if (!selectedDotEnvFile || !fileExists) {
|
||||
setSelectedDotEnvFile(dotEnvFiles[0].filename);
|
||||
}
|
||||
}, [dotEnvFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environments?.length) {
|
||||
setSelectedEnvironment(null);
|
||||
@@ -79,44 +129,34 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
}
|
||||
}, [envUids, environments, prevEnvUids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingEnvUid) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
|
||||
handleCancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [renamingEnvUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreatingInline) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
|
||||
handleCancelCreate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isCreatingInline]);
|
||||
|
||||
const handleEnvironmentClick = (env) => {
|
||||
if (activeView === 'dotenv' && isDotEnvModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
if (!isModified) {
|
||||
setSelectedEnvironment(env);
|
||||
setActiveView('environment');
|
||||
setEnvironmentsExpanded(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDotEnvClick = (filename) => {
|
||||
if (isModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
setSelectedDotEnvFile(filename);
|
||||
setActiveView('dotenv');
|
||||
setDotEnvExpanded(true);
|
||||
};
|
||||
|
||||
const handleEnvironmentDoubleClick = (env) => {
|
||||
setRenamingEnvUid(env.uid);
|
||||
setNewEnvName(env.name);
|
||||
@@ -127,7 +167,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleActivateEnvironment = (e, env) => {
|
||||
const handleActivateEnvironment = useCallback((e, env) => {
|
||||
e.stopPropagation();
|
||||
dispatch(selectGlobalEnvironment({ environmentUid: env.uid }))
|
||||
.then(() => {
|
||||
@@ -136,11 +176,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
.catch(() => {
|
||||
toast.error('Failed to activate environment');
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const validateEnvironmentName = (name, excludeUid = null) => {
|
||||
if (!name || name.trim() === '') {
|
||||
@@ -152,8 +188,9 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
}
|
||||
|
||||
const trimmedName = name.toLowerCase().trim();
|
||||
const isDuplicate = globalEnvs.some((env) =>
|
||||
env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName);
|
||||
const isDuplicate = globalEnvs?.some(
|
||||
(env) => env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName
|
||||
);
|
||||
if (isDuplicate) {
|
||||
return 'Environment already exists';
|
||||
}
|
||||
@@ -162,7 +199,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
};
|
||||
|
||||
const handleCreateEnvClick = () => {
|
||||
if (!isModified) {
|
||||
if (!isModified && !isDotEnvModified) {
|
||||
setIsCreatingInline(true);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
@@ -174,11 +211,13 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
const handleCancelCreate = useCallback(() => {
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
|
||||
|
||||
const handleSaveNewEnv = () => {
|
||||
const error = validateEnvironmentName(newEnvName);
|
||||
@@ -245,14 +284,16 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setRenamingEnvUid(null);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid);
|
||||
|
||||
const handleImportClick = () => {
|
||||
if (!isModified) {
|
||||
if (!isModified && !isDotEnvModified) {
|
||||
setOpenImportModal(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
@@ -271,12 +312,196 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEnvironments = environments?.filter((env) =>
|
||||
env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
|
||||
const handleSaveDotEnv = (variables) => {
|
||||
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
|
||||
return dispatch(saveWorkspaceDotEnvVariables(workspace.uid, variables, selectedDotEnvFile));
|
||||
};
|
||||
|
||||
const handleSaveDotEnvRaw = (content) => {
|
||||
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
|
||||
return dispatch(saveWorkspaceDotEnvRaw(workspace.uid, content, selectedDotEnvFile));
|
||||
};
|
||||
|
||||
const handleCreateDotEnvInlineClick = () => {
|
||||
if (isModified || isDotEnvModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
setIsCreatingDotEnvInline(true);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
setTimeout(() => {
|
||||
dotEnvInputRef.current?.focus();
|
||||
const input = dotEnvInputRef.current;
|
||||
if (input) {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleCancelDotEnvCreate = useCallback(() => {
|
||||
setIsCreatingDotEnvInline(false);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline);
|
||||
|
||||
const validateDotEnvName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (!name.startsWith('.env')) {
|
||||
return 'File name must start with .env';
|
||||
}
|
||||
|
||||
const validPattern = /^\.env[a-zA-Z0-9._-]*$/;
|
||||
if (!validPattern.test(name)) {
|
||||
return 'Invalid file name';
|
||||
}
|
||||
|
||||
const exists = dotEnvFiles.some((f) => f.filename === name);
|
||||
if (exists) {
|
||||
return 'File already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSaveNewDotEnv = () => {
|
||||
const error = validateDotEnvName(newDotEnvName);
|
||||
if (error) {
|
||||
setDotEnvNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(createWorkspaceDotEnvFile(workspace.uid, newDotEnvName))
|
||||
.then(() => {
|
||||
toast.success(`${newDotEnvName} file created!`);
|
||||
setIsCreatingDotEnvInline(false);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
setSelectedDotEnvFile(newDotEnvName);
|
||||
setActiveView('dotenv');
|
||||
setDotEnvExpanded(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message || 'Failed to create .env file');
|
||||
});
|
||||
};
|
||||
|
||||
const handleDotEnvNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
if (!value.startsWith('.env')) {
|
||||
setNewDotEnvName('.env');
|
||||
} else {
|
||||
setNewDotEnvName(value);
|
||||
}
|
||||
if (dotEnvNameError) {
|
||||
setDotEnvNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDotEnvNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveNewDotEnv();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelDotEnvCreate();
|
||||
} else if (e.key === 'Backspace') {
|
||||
const input = e.target;
|
||||
if (input.selectionStart <= 4 && input.selectionEnd <= 4) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDotEnvFile = (filename) => {
|
||||
dispatch(deleteWorkspaceDotEnvFile(workspace.uid, filename))
|
||||
.then(() => {
|
||||
toast.success(`${filename} file deleted!`);
|
||||
setIsDotEnvModified(false);
|
||||
if (selectedDotEnvFile === filename) {
|
||||
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
|
||||
if (remainingFiles.length > 0) {
|
||||
setSelectedDotEnvFile(remainingFiles[0].filename);
|
||||
} else {
|
||||
setActiveView('environment');
|
||||
if (environments?.length) {
|
||||
const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0];
|
||||
setSelectedEnvironment(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message || 'Failed to delete .env file');
|
||||
});
|
||||
};
|
||||
|
||||
const handleDotEnvViewModeChange = (mode) => {
|
||||
setDotEnvViewMode(mode);
|
||||
};
|
||||
|
||||
const filteredEnvironments
|
||||
= environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
|
||||
|
||||
const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile);
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) {
|
||||
return (
|
||||
<DotEnvFileDetails
|
||||
title={selectedDotEnvFile}
|
||||
onDelete={() => handleDeleteDotEnvFile(selectedDotEnvFile)}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
onViewModeChange={handleDotEnvViewModeChange}
|
||||
>
|
||||
<DotEnvFileEditor
|
||||
variables={selectedDotEnvData?.variables || []}
|
||||
onSave={handleSaveDotEnv}
|
||||
onSaveRaw={handleSaveDotEnvRaw}
|
||||
isModified={isDotEnvModified}
|
||||
setIsModified={setIsDotEnvModified}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
/>
|
||||
</DotEnvFileDetails>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEnvironment) {
|
||||
return (
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<Button size="sm" color="secondary" onClick={() => handleCreateEnvClick()}>
|
||||
Create Environment
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" onClick={() => handleImportClick()}>
|
||||
Import Environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
|
||||
|
||||
<div className="environments-container">
|
||||
@@ -286,45 +511,113 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Environments</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<IconUpload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="title">Variables</h2>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
placeholder="Search..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<div className="sections-container">
|
||||
<CollapsibleSection
|
||||
title="Environments"
|
||||
expanded={environmentsExpanded}
|
||||
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={classnames('environment-item', {
|
||||
active: activeView === 'environment' && selectedEnvironment?.uid === env.uid,
|
||||
renaming: renamingEnvUid === env.uid,
|
||||
activated: activeEnvironmentUid === env.uid
|
||||
})}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -332,6 +625,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
@@ -340,7 +634,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
@@ -348,7 +642,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
@@ -356,79 +650,94 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
|
||||
|
||||
{filteredEnvironments.length === 0 && !isCreatingInline && (
|
||||
<div className="no-env-file">
|
||||
<span>No environments</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
actions={(
|
||||
<button
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="environments-list">
|
||||
{dotEnvFiles.map((file) => (
|
||||
<div
|
||||
key={file.filename}
|
||||
className={classnames('environment-item', {
|
||||
active: activeView === 'dotenv' && selectedDotEnvFile === file.filename
|
||||
})}
|
||||
onClick={() => handleDotEnvClick(file.filename)}
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="environment-name">{file.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingDotEnvInline && (
|
||||
<div className="environment-item creating" ref={dotEnvCreateContainerRef}>
|
||||
<input
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewDotEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Create"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelDotEnvCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dotEnvNameError && isCreatingDotEnvInline && <div className="env-error">{dotEnvNameError}</div>}
|
||||
|
||||
{dotEnvFiles.length === 0 && !isCreatingDotEnvInline && (
|
||||
<div className="no-env-file">
|
||||
<span>No .env files</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && (
|
||||
<div className="env-error">{envNameError}</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content */}
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,67 +1,34 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const DefaultTab = ({ setTab }) => (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
|
||||
Create Environment
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
|
||||
Import Environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const WorkspaceEnvironments = ({ workspace }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
|
||||
if (!globalEnvironments || !globalEnvironments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
environments={globalEnvironments || []}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={null}
|
||||
workspace={workspace}
|
||||
setShowExportModal={setShowExportModal}
|
||||
/>
|
||||
{showExportModal && (
|
||||
<ExportEnvironmentModal
|
||||
onClose={() => setShowExportModal(false)}
|
||||
environments={globalEnvironments}
|
||||
environments={globalEnvironments || []}
|
||||
environmentType="global"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// See https://usehooks.com/useOnClickOutside/
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useOnClickOutside = (ref, handler) => {
|
||||
const useOnClickOutside = (ref, handler, enabled = true) => {
|
||||
useEffect(
|
||||
() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const listener = (event) => {
|
||||
// Do nothing if clicking ref's element or descendant elements
|
||||
if (!ref.current || ref.current.contains(event.target)) {
|
||||
@@ -27,7 +29,7 @@ const useOnClickOutside = (ref, handler) => {
|
||||
// ... callback/cleanup to run every render. It's not a big deal ...
|
||||
// ... but to optimize you can wrap handler in useCallback before ...
|
||||
// ... passing it into this hook.
|
||||
[ref, handler]
|
||||
[ref, handler, enabled]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
runFolderEvent,
|
||||
runRequestEvent,
|
||||
scriptEnvironmentUpdateEvent,
|
||||
streamDataReceived
|
||||
streamDataReceived,
|
||||
setDotEnvVariables
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { workspaceDotEnvUpdateEvent } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
@@ -226,6 +227,33 @@ const useIpcEvents = () => {
|
||||
dispatch(workspaceEnvUpdateEvent({ processEnvVariables: val.processEnvVariables }));
|
||||
});
|
||||
|
||||
const removeDotEnvFileUpdateListener = ipcRenderer.on('main:dotenv-file-update', (val) => {
|
||||
const { type, collectionUid, workspaceUid, filename, variables, exists, processEnvVariables } = val;
|
||||
|
||||
if (type === 'collection' && collectionUid) {
|
||||
dispatch(setDotEnvVariables({
|
||||
collectionUid,
|
||||
variables,
|
||||
exists,
|
||||
filename
|
||||
}));
|
||||
if (filename === '.env') {
|
||||
dispatch(processEnvUpdateEvent({ collectionUid, processEnvVariables }));
|
||||
}
|
||||
} else if (type === 'workspace' && workspaceUid) {
|
||||
dispatch(setWorkspaceDotEnvVariables({
|
||||
workspaceUid,
|
||||
variables,
|
||||
exists,
|
||||
filename
|
||||
}));
|
||||
if (filename === '.env') {
|
||||
dispatch(workspaceDotEnvUpdateEvent(val));
|
||||
dispatch(workspaceEnvUpdateEvent({ processEnvVariables }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => {
|
||||
console[val.type](...val.args);
|
||||
dispatch(addLog({
|
||||
@@ -321,6 +349,7 @@ const useIpcEvents = () => {
|
||||
removeRunRequestEventListener();
|
||||
removeProcessEnvUpdatesListener();
|
||||
removeWorkspaceDotEnvUpdatesListener();
|
||||
removeDotEnvFileUpdateListener();
|
||||
removeConsoleLogListener();
|
||||
removeConfigUpdatesListener();
|
||||
removeShowPreferencesListener();
|
||||
|
||||
@@ -248,4 +248,16 @@ export const getSystemProxyVariables = () => (dispatch, getState) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const refreshSystemProxy = () => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:refresh-system-proxy')
|
||||
.then((variables) => {
|
||||
dispatch(updateSystemProxyVariables(variables));
|
||||
return variables;
|
||||
})
|
||||
.then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export default appSlice.reducer;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
|
||||
import { parseQueryParams, extractPromptVariables } from '@usebruno/common/utils';
|
||||
import { REQUEST_TYPES } from 'utils/common/constants';
|
||||
import { REQUEST_TYPES, DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import filter from 'lodash/filter';
|
||||
import find from 'lodash/find';
|
||||
@@ -1909,8 +1909,8 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
|
||||
Modal Save writes what the user sees:
|
||||
- Non-ephemeral vars are saved as-is (without metadata)
|
||||
- Ephemeral vars:
|
||||
- if persistedValue exists, save that (restore original value)
|
||||
- otherwise filter out (don't save script-created ephemeral vars)
|
||||
- if persistedValue exists, save that (explicit persisted case)
|
||||
- otherwise save the current UI value (treat as user-authored)
|
||||
*/
|
||||
const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
|
||||
environment.variables = persisted;
|
||||
@@ -2642,7 +2642,7 @@ export const importCollection = (collection, collectionLocation, options = {}) =
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || 'bru');
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
const workspaceCollection = {
|
||||
@@ -2896,3 +2896,71 @@ export const openCollectionSettings
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
export const saveDotEnvVariables = (collectionUid, variables, filename = '.env') => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-dotenv-variables', collection.pathname, variables, filename)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const saveDotEnvRaw = (collectionUid, content, filename = '.env') => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-dotenv-raw', collection.pathname, content, filename)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const createDotEnvFile = (collectionUid, filename = '.env') => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-dotenv-file', collection.pathname, filename)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-dotenv-file', collection.pathname, filename)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -66,6 +66,51 @@ const wsStatusCodes = {
|
||||
1015: 'TLS_HANDSHAKE'
|
||||
};
|
||||
|
||||
/**
|
||||
* Preserves UIDs from existing array items when merging with new data.
|
||||
* UIDs are matched by position to keep React keys stable after file reloads.
|
||||
*/
|
||||
const preserveUidsAtPaths = (existing, updated, paths) => {
|
||||
if (!existing || !updated) return updated;
|
||||
|
||||
const merged = cloneDeep(updated);
|
||||
|
||||
paths.forEach((path) => {
|
||||
const newArray = get(merged, path);
|
||||
const existingArray = get(existing, path, []);
|
||||
|
||||
if (Array.isArray(newArray) && newArray.length) {
|
||||
set(
|
||||
merged,
|
||||
path,
|
||||
newArray.map((item, i) => (existingArray[i]?.uid ? { ...item, uid: existingArray[i].uid } : item))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
// Paths containing arrays with UIDs that need preservation
|
||||
const REQUEST_UID_PATHS = [
|
||||
'params',
|
||||
'headers',
|
||||
'vars.req',
|
||||
'vars.res',
|
||||
'assertions',
|
||||
'body.formUrlEncoded',
|
||||
'body.multipartForm',
|
||||
'body.file'
|
||||
];
|
||||
|
||||
const ROOT_UID_PATHS = ['request.headers', 'request.vars.req', 'request.vars.res'];
|
||||
|
||||
const mergeRequestWithPreservedUids = (existingRequest, newRequest) =>
|
||||
preserveUidsAtPaths(existingRequest, newRequest, REQUEST_UID_PATHS);
|
||||
|
||||
const mergeRootWithPreservedUids = (existingRoot, newRoot) =>
|
||||
preserveUidsAtPaths(existingRoot, newRoot, ROOT_UID_PATHS);
|
||||
|
||||
const initialState = {
|
||||
collections: [],
|
||||
collectionSortOrder: 'default',
|
||||
@@ -425,6 +470,37 @@ export const collectionsSlice = createSlice({
|
||||
collection.workspaceProcessEnvVariables = processEnvVariables;
|
||||
});
|
||||
},
|
||||
setDotEnvVariables: (state, action) => {
|
||||
const { collectionUid, variables, exists, filename = '.env' } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
|
||||
if (collection) {
|
||||
if (!collection.dotEnvFiles) {
|
||||
collection.dotEnvFiles = [];
|
||||
}
|
||||
|
||||
const existingIndex = collection.dotEnvFiles.findIndex((f) => f.filename === filename);
|
||||
if (existingIndex >= 0) {
|
||||
if (exists) {
|
||||
collection.dotEnvFiles[existingIndex] = { filename, variables, exists };
|
||||
} else {
|
||||
collection.dotEnvFiles.splice(existingIndex, 1);
|
||||
}
|
||||
} else if (exists) {
|
||||
collection.dotEnvFiles.push({ filename, variables, exists });
|
||||
}
|
||||
|
||||
collection.dotEnvFiles.sort((a, b) => {
|
||||
if (a.filename === '.env') return -1;
|
||||
if (b.filename === '.env') return 1;
|
||||
return a.filename.localeCompare(b.filename);
|
||||
});
|
||||
|
||||
const mainEnvFile = collection.dotEnvFiles.find((f) => f.filename === '.env');
|
||||
collection.dotEnvVariables = mainEnvFile?.variables || [];
|
||||
collection.dotEnvExists = mainEnvFile?.exists || false;
|
||||
}
|
||||
},
|
||||
requestCancelled: (state, action) => {
|
||||
const { itemUid, collectionUid, seq, timestamp } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
@@ -2561,7 +2637,7 @@ export const collectionsSlice = createSlice({
|
||||
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
|
||||
if (isCollectionRoot) {
|
||||
if (collection) {
|
||||
collection.root = file.data;
|
||||
collection.root = mergeRootWithPreservedUids(collection.root, file.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2573,7 +2649,7 @@ export const collectionsSlice = createSlice({
|
||||
if (file?.data?.meta?.name) {
|
||||
folderItem.name = file?.data?.meta?.name;
|
||||
}
|
||||
folderItem.root = file.data;
|
||||
folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data);
|
||||
if (file?.data?.meta?.seq) {
|
||||
folderItem.seq = file.data?.meta?.seq;
|
||||
}
|
||||
@@ -2621,7 +2697,7 @@ export const collectionsSlice = createSlice({
|
||||
currentItem.type = file.data.type;
|
||||
currentItem.seq = file.data.seq;
|
||||
currentItem.tags = file.data.tags;
|
||||
currentItem.request = file.data.request;
|
||||
currentItem.request = mergeRequestWithPreservedUids(currentItem.request, file.data.request);
|
||||
currentItem.filename = file.meta.name;
|
||||
currentItem.pathname = file.meta.pathname;
|
||||
currentItem.settings = file.data.settings;
|
||||
@@ -2700,7 +2776,7 @@ export const collectionsSlice = createSlice({
|
||||
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
|
||||
if (isCollectionRoot) {
|
||||
if (collection) {
|
||||
collection.root = file.data;
|
||||
collection.root = mergeRootWithPreservedUids(collection.root, file.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2715,7 +2791,7 @@ export const collectionsSlice = createSlice({
|
||||
if (file?.data?.meta?.seq) {
|
||||
folderItem.seq = file?.data?.meta?.seq;
|
||||
}
|
||||
folderItem.root = file.data;
|
||||
folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2740,7 +2816,7 @@ export const collectionsSlice = createSlice({
|
||||
item.type = file.data.type;
|
||||
item.seq = file.data.seq;
|
||||
item.tags = file.data.tags;
|
||||
item.request = file.data.request;
|
||||
item.request = mergeRequestWithPreservedUids(item.request, file.data.request);
|
||||
item.settings = file.data.settings;
|
||||
item.examples = file.data.examples;
|
||||
item.filename = file.meta.name;
|
||||
@@ -3485,6 +3561,7 @@ export const {
|
||||
scriptEnvironmentUpdateEvent,
|
||||
processEnvUpdateEvent,
|
||||
workspaceEnvUpdateEvent,
|
||||
setDotEnvVariables,
|
||||
requestCancelled,
|
||||
responseReceived,
|
||||
runGrpcRequestEvent,
|
||||
|
||||
@@ -760,3 +760,83 @@ export const importWorkspaceAction = (zipFilePath, extractLocation) => {
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const saveWorkspaceDotEnvVariables = (workspaceUid, variables, filename = '.env') => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
return reject(new Error('Workspace not found'));
|
||||
}
|
||||
|
||||
if (!workspace.pathname) {
|
||||
return reject(new Error('Workspace path not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-workspace-dotenv-variables', { workspacePath: workspace.pathname, variables, filename })
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const saveWorkspaceDotEnvRaw = (workspaceUid, content, filename = '.env') => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
return reject(new Error('Workspace not found'));
|
||||
}
|
||||
|
||||
if (!workspace.pathname) {
|
||||
return reject(new Error('Workspace path not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-workspace-dotenv-raw', { workspacePath: workspace.pathname, content, filename })
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const createWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
return reject(new Error('Workspace not found'));
|
||||
}
|
||||
|
||||
if (!workspace.pathname) {
|
||||
return reject(new Error('Workspace path not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-workspace-dotenv-file', { workspacePath: workspace.pathname, filename })
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
return reject(new Error('Workspace not found'));
|
||||
}
|
||||
|
||||
if (!workspace.pathname) {
|
||||
return reject(new Error('Workspace path not found'));
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-workspace-dotenv-file', { workspacePath: workspace.pathname, filename })
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,6 +84,38 @@ export const workspacesSlice = createSlice({
|
||||
if (workspace) {
|
||||
workspace.processEnvVariables = processEnvVariables;
|
||||
}
|
||||
},
|
||||
|
||||
setWorkspaceDotEnvVariables: (state, action) => {
|
||||
const { workspaceUid, variables, exists, filename = '.env' } = action.payload;
|
||||
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (workspace) {
|
||||
if (!workspace.dotEnvFiles) {
|
||||
workspace.dotEnvFiles = [];
|
||||
}
|
||||
|
||||
const existingIndex = workspace.dotEnvFiles.findIndex((f) => f.filename === filename);
|
||||
if (existingIndex >= 0) {
|
||||
if (exists) {
|
||||
workspace.dotEnvFiles[existingIndex] = { filename, variables, exists };
|
||||
} else {
|
||||
workspace.dotEnvFiles.splice(existingIndex, 1);
|
||||
}
|
||||
} else if (exists) {
|
||||
workspace.dotEnvFiles.push({ filename, variables, exists });
|
||||
}
|
||||
|
||||
workspace.dotEnvFiles.sort((a, b) => {
|
||||
if (a.filename === '.env') return -1;
|
||||
if (b.filename === '.env') return 1;
|
||||
return a.filename.localeCompare(b.filename);
|
||||
});
|
||||
|
||||
const mainEnvFile = workspace.dotEnvFiles.find((f) => f.filename === '.env');
|
||||
workspace.dotEnvVariables = mainEnvFile?.variables || [];
|
||||
workspace.dotEnvExists = mainEnvFile?.exists || false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -96,7 +128,8 @@ export const {
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
updateWorkspaceLoadingState,
|
||||
workspaceDotEnvUpdateEvent
|
||||
workspaceDotEnvUpdateEvent,
|
||||
setWorkspaceDotEnvVariables
|
||||
} = workspacesSlice.actions;
|
||||
|
||||
export default workspacesSlice.reducer;
|
||||
|
||||
65
packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js
Normal file
65
packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
|
||||
|
||||
const SubMenuItem = ({
|
||||
item,
|
||||
onRootClose,
|
||||
submenuPlacement,
|
||||
getMenuItemProps,
|
||||
renderMenuItemContent,
|
||||
MenuDropdownComponent
|
||||
}) => {
|
||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||
const isLeftPlacement = submenuPlacement === 'left';
|
||||
const submenuTippyPlacement = isLeftPlacement ? 'left-start' : 'right-start';
|
||||
const ArrowIcon = isLeftPlacement ? IconChevronLeft : IconChevronRight;
|
||||
|
||||
const submenuItemsWithClose = item.submenu.map((subItem) => {
|
||||
if (subItem.type === 'divider') return subItem;
|
||||
return {
|
||||
...subItem,
|
||||
onClick: () => {
|
||||
subItem.onClick?.();
|
||||
onRootClose();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const itemProps = getMenuItemProps(item, {
|
||||
'className': 'has-submenu',
|
||||
'aria-haspopup': 'true',
|
||||
'aria-expanded': submenuOpen,
|
||||
'aria-current': undefined // submenu triggers don't need aria-current
|
||||
});
|
||||
|
||||
const arrowElement = (
|
||||
<span className="submenu-arrow">
|
||||
<ArrowIcon size={14} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="submenu-trigger"
|
||||
onMouseEnter={() => setSubmenuOpen(true)}
|
||||
onMouseLeave={() => setSubmenuOpen(false)}
|
||||
>
|
||||
<MenuDropdownComponent
|
||||
items={submenuItemsWithClose}
|
||||
placement={submenuTippyPlacement}
|
||||
opened={submenuOpen}
|
||||
onChange={setSubmenuOpen}
|
||||
showTickMark={false}
|
||||
submenuPlacement={submenuPlacement}
|
||||
appendTo={() => document.body}
|
||||
offset={[0, 0]}
|
||||
>
|
||||
<div {...itemProps}>
|
||||
{renderMenuItemContent(item, arrowElement)}
|
||||
</div>
|
||||
</MenuDropdownComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubMenuItem;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { forwardRef, useRef, useCallback, useState, useImperativeHandle, useEffect, useMemo } from 'react';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import SubMenuItem from './SubMenuItem';
|
||||
|
||||
// Constants
|
||||
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'];
|
||||
@@ -31,6 +32,7 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
|
||||
* - testId: string (optional, for testing, for items only)
|
||||
* - disabled: boolean (optional, for items only)
|
||||
* - className: string (optional, additional CSS classes for the item)
|
||||
* - submenu: Array (optional, array of menu items for nested submenu, opens on hover)
|
||||
*
|
||||
* Grouped format: [{name: string, options: [{id, label, ...}]}, ...]
|
||||
* Flat format: [{id, label, ...}, ...]
|
||||
@@ -46,6 +48,7 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
|
||||
* @param {boolean} props.showGroupDividers - Optional flag to show dividers between groups in grouped format (default: true)
|
||||
* @param {string} props.groupStyle - Style for grouped items: 'action' (default, normal case) or 'select' (uppercase labels, indented items)
|
||||
* @param {boolean} props.autoFocusFirstOption - Optional flag to auto-focus first option when dropdown opens (default: false)
|
||||
* @param {string} props.submenuPlacement - Placement of submenus: 'right' (default) or 'left'. Controls both position and arrow direction.
|
||||
* @param {Object} props.dropdownProps - Other props passed to underlying Dropdown component
|
||||
* @param {React.Ref} ref - Optional ref to expose open/close methods
|
||||
*/
|
||||
@@ -63,6 +66,7 @@ const MenuDropdown = forwardRef(({
|
||||
showGroupDividers = true,
|
||||
groupStyle = 'action',
|
||||
autoFocusFirstOption = false,
|
||||
submenuPlacement = 'right',
|
||||
'data-testid': testId = 'menu-dropdown',
|
||||
...dropdownProps
|
||||
}, ref) => {
|
||||
@@ -264,7 +268,11 @@ const MenuDropdown = forwardRef(({
|
||||
}, [isOpen, updateOpenState]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
const handleClickOutside = useCallback(() => {
|
||||
const handleClickOutside = useCallback((instance, event) => {
|
||||
// Don't close if clicking inside a submenu (another tippy popper)
|
||||
if (event?.target?.closest?.('[data-tippy-root]')) {
|
||||
return;
|
||||
}
|
||||
updateOpenState(false);
|
||||
}, [updateOpenState]);
|
||||
|
||||
@@ -346,39 +354,75 @@ const MenuDropdown = forwardRef(({
|
||||
return section;
|
||||
};
|
||||
|
||||
// Render menu item
|
||||
const renderMenuItem = (item) => {
|
||||
// Get common props for menu items (shared between regular items and submenu triggers)
|
||||
const getMenuItemProps = (item, extraProps = {}) => {
|
||||
const selectIndentClass = item.groupStyle === 'select' ? 'dropdown-item-select' : '';
|
||||
const isActive = item.id === selectedItemId;
|
||||
const activeClass = isActive ? 'dropdown-item-active' : '';
|
||||
|
||||
// Destructure className from extraProps to avoid it being overwritten by spread
|
||||
const { className: extraClassName, ...restExtraProps } = extraProps;
|
||||
|
||||
return {
|
||||
'className': `dropdown-item ${item.disabled ? 'disabled' : ''} ${selectIndentClass} ${activeClass} ${extraClassName || ''} ${item.className || ''}`.trim(),
|
||||
'role': 'menuitem',
|
||||
'data-item-id': item.id,
|
||||
'tabIndex': item.disabled ? -1 : 0,
|
||||
'aria-label': item.ariaLabel,
|
||||
'aria-disabled': item.disabled,
|
||||
'aria-current': isActive ? 'true' : undefined,
|
||||
'title': item.title,
|
||||
'data-testid': `${testId}-${String(item.id).toLowerCase()}`,
|
||||
...restExtraProps
|
||||
};
|
||||
};
|
||||
|
||||
// Render the content inside a menu item (leftSection, label, and rightSection/arrow)
|
||||
const renderMenuItemContent = (item, rightContent = null) => (
|
||||
<>
|
||||
{renderSection(item.leftSection)}
|
||||
<span className="dropdown-label">{item.label}</span>
|
||||
{rightContent}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render menu item
|
||||
const renderMenuItem = (item) => {
|
||||
if (item.submenu) {
|
||||
return (
|
||||
<SubMenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRootClose={() => updateOpenState(false)}
|
||||
submenuPlacement={submenuPlacement}
|
||||
getMenuItemProps={getMenuItemProps}
|
||||
renderMenuItemContent={renderMenuItemContent}
|
||||
MenuDropdownComponent={MenuDropdown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemProps = getMenuItemProps(item);
|
||||
|
||||
const rightContent = item.rightSection ? (
|
||||
<div
|
||||
className="dropdown-right-section"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{renderSection(item.rightSection)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`dropdown-item ${item.disabled ? 'disabled' : ''} ${selectIndentClass} ${activeClass} ${item.className || ''}`.trim()}
|
||||
role="menuitem"
|
||||
data-item-id={item.id}
|
||||
{...itemProps}
|
||||
onClick={() => !item.disabled && handleItemClick(item)}
|
||||
tabIndex={item.disabled ? -1 : 0}
|
||||
aria-label={item.ariaLabel}
|
||||
aria-disabled={item.disabled}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
title={item.title}
|
||||
data-testid={`${testId}-${String(item.id).toLowerCase()}`}
|
||||
>
|
||||
{renderSection(item.leftSection)}
|
||||
<span className="dropdown-label">{item.label}</span>
|
||||
{item.rightSection && (
|
||||
<div
|
||||
className="dropdown-right-section"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{renderSection(item.rightSection)}
|
||||
</div>
|
||||
)}
|
||||
{renderMenuItemContent(item, rightContent)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,6 +76,8 @@ const STATIC_API_HINTS = {
|
||||
'bru.setNextRequest(requestName)',
|
||||
'bru.getRequestVar(key)',
|
||||
'bru.runRequest(requestPathName)',
|
||||
'bru.sendRequest(requestConfig)',
|
||||
'bru.sendRequest(requestConfig, callback)',
|
||||
'bru.getAssertionResults()',
|
||||
'bru.getTestResults()',
|
||||
'bru.sleep(ms)',
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const REQUEST_TYPES = ['http-request', 'graphql-request', 'grpc-request', 'ws-request'];
|
||||
|
||||
export const DEFAULT_COLLECTION_FORMAT = 'yml';
|
||||
|
||||
@@ -12,31 +12,24 @@ const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
|
||||
return rest;
|
||||
};
|
||||
|
||||
const isPersistableEnvVarForSave = (v) => {
|
||||
if (!v) return false;
|
||||
return !v.ephemeral || v.persistedValue !== undefined;
|
||||
};
|
||||
|
||||
const toPersistedEnvVarForSave = (v) => {
|
||||
const { ephemeral, persistedValue, ...rest } = v || {};
|
||||
return v?.ephemeral ? (persistedValue !== undefined ? { ...rest, value: persistedValue } : rest) : rest;
|
||||
};
|
||||
|
||||
// mode 'save': filters out ephemeral vars without persistedValue (script-created, never on disk)
|
||||
// mode 'merge': same as 'save', but also includes ephemeral vars explicitly persisted this run
|
||||
/*
|
||||
High-level builder for persisted variables
|
||||
- mode 'save': write what the user sees
|
||||
- mode 'merge': write only allowed vars (non-ephemeral, ephemerals with persistedValue, or explicitly persisted this run)
|
||||
*/
|
||||
export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
|
||||
const src = Array.isArray(variables) ? variables : [];
|
||||
if (mode === 'merge') {
|
||||
const names = persistedNames instanceof Set ? persistedNames : new Set();
|
||||
return src
|
||||
.filter(isPersistableEnvVarForMerge(names))
|
||||
.map(toPersistedEnvVarForMerge(names));
|
||||
return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
|
||||
}
|
||||
|
||||
// default to save mode
|
||||
return src
|
||||
.filter(isPersistableEnvVarForSave)
|
||||
.map(toPersistedEnvVarForSave);
|
||||
return src.map(toPersistedEnvVarForSave);
|
||||
};
|
||||
|
||||
export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const constants = require('../constants');
|
||||
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection');
|
||||
const { hasExecutableTestInScript } = require('../utils/request');
|
||||
const { createSkippedFileResults } = require('../utils/run');
|
||||
const { getSystemProxy } = require('@usebruno/requests');
|
||||
const command = 'run [paths...]';
|
||||
const desc = 'Run one or more requests/folders';
|
||||
|
||||
@@ -374,7 +375,7 @@ const handler = async function (argv) {
|
||||
result.__name__ = nameOverride || path.basename(filePath, fileExt);
|
||||
} else {
|
||||
const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
||||
const envJson = parseEnvironment(content);
|
||||
const envJson = parseEnvironment(content, { format: 'bru' });
|
||||
result = getEnvVars(envJson);
|
||||
result.__name__ = nameOverride || path.basename(filePath, '.bru');
|
||||
}
|
||||
@@ -608,6 +609,15 @@ const handler = async function (argv) {
|
||||
|
||||
const runtime = getJsSandboxRuntime(sandbox);
|
||||
|
||||
// Fetch system proxy once for all requests (skip if --noproxy flag is set)
|
||||
if (!noproxy) {
|
||||
try {
|
||||
options['cachedSystemProxy'] = await getSystemProxy();
|
||||
} catch (error) {
|
||||
console.warn(chalk.yellow('Failed to detect system proxy, continuing without system proxy'));
|
||||
}
|
||||
}
|
||||
|
||||
const runSingleRequestByPathname = async (relativeItemPathname) => {
|
||||
const ext = FORMAT_CONFIG[collection.format].ext;
|
||||
return new Promise(async (resolve, reject) => {
|
||||
|
||||
@@ -15,7 +15,6 @@ const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { makeAxiosInstance } = require('../utils/axios-instance');
|
||||
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
|
||||
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
|
||||
const { getSystemProxy } = require('@usebruno/requests');
|
||||
const path = require('path');
|
||||
const { parseDataFromResponse } = require('../utils/common');
|
||||
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
|
||||
@@ -171,6 +170,37 @@ const runSingleRequest = async function (
|
||||
const scriptingConfig = get(brunoConfig, 'scripts', {});
|
||||
scriptingConfig.runtime = runtime;
|
||||
|
||||
// Build certsAndProxyConfig for bru.sendRequest
|
||||
const options = getOptions();
|
||||
const systemProxyConfig = options['cachedSystemProxy'];
|
||||
const sendRequestInterpolationOptions = {
|
||||
envVars: envVariables,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
globalEnvVars,
|
||||
collectionVariables: request.collectionVariables || {},
|
||||
folderVariables: request.folderVariables || {},
|
||||
requestVariables: request.requestVariables || {}
|
||||
};
|
||||
const rawClientCertificates = get(brunoConfig, 'clientCertificates');
|
||||
const rawProxyConfig = get(brunoConfig, 'proxy', {});
|
||||
const certsAndProxyConfig = {
|
||||
collectionPath,
|
||||
options: {
|
||||
noproxy: get(options, 'noproxy', false),
|
||||
shouldVerifyTls: !get(options, 'insecure', false),
|
||||
shouldUseCustomCaCertificate: !!options['cacert'],
|
||||
customCaCertificateFilePath: options['cacert'],
|
||||
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
|
||||
},
|
||||
clientCertificates: rawClientCertificates ? interpolateObject(rawClientCertificates, sendRequestInterpolationOptions) : undefined,
|
||||
collectionLevelProxy: transformProxyConfig(interpolateObject(rawProxyConfig, sendRequestInterpolationOptions)),
|
||||
systemProxyConfig
|
||||
};
|
||||
|
||||
// Add certsAndProxyConfig to request object for bru.sendRequest
|
||||
request.certsAndProxyConfig = certsAndProxyConfig;
|
||||
|
||||
// run pre request script
|
||||
const requestScriptFile = get(request, 'script.req');
|
||||
const collectionName = collection?.brunoConfig?.name;
|
||||
@@ -238,9 +268,9 @@ const runSingleRequest = async function (
|
||||
request.url = `http://${request.url}`;
|
||||
}
|
||||
|
||||
const options = getOptions();
|
||||
const insecure = get(options, 'insecure', false);
|
||||
const noproxy = get(options, 'noproxy', false);
|
||||
const cachedSystemProxy = get(options, 'cachedSystemProxy', null);
|
||||
const httpsAgentRequestFields = {};
|
||||
|
||||
if (insecure) {
|
||||
@@ -310,10 +340,11 @@ const runSingleRequest = async function (
|
||||
proxyMode = 'on';
|
||||
} else if (!collectionProxyDisabled && collectionProxyInherit) {
|
||||
// Inherit from system proxy
|
||||
const systemProxy = await getSystemProxy();
|
||||
const { http_proxy, https_proxy } = systemProxy;
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
proxyMode = 'system';
|
||||
if (cachedSystemProxy) {
|
||||
const { http_proxy, https_proxy } = cachedSystemProxy;
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
proxyMode = 'system';
|
||||
}
|
||||
}
|
||||
// else: no system proxy available, proxyMode stays 'off'
|
||||
}
|
||||
@@ -357,8 +388,7 @@ const runSingleRequest = async function (
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
try {
|
||||
const systemProxy = await getSystemProxy();
|
||||
const { http_proxy, https_proxy, no_proxy } = systemProxy;
|
||||
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
|
||||
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
|
||||
const parsedUrl = new URL(request.url);
|
||||
const isHttpsRequest = parsedUrl.protocol === 'https:';
|
||||
@@ -505,7 +535,7 @@ const runSingleRequest = async function (
|
||||
const proxyConfig = get(brunoConfig, 'proxy');
|
||||
const interpolatedClientCertificates = clientCertificates ? interpolateObject(clientCertificates, oauth2InterpolationOptions) : undefined;
|
||||
const interpolatedProxyConfig = proxyConfig ? interpolateObject(proxyConfig, oauth2InterpolationOptions) : undefined;
|
||||
const systemProxyConfig = await getSystemProxy();
|
||||
const systemProxyConfig = cachedSystemProxy;
|
||||
|
||||
const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({
|
||||
requestUrl: oauth2RequestUrl,
|
||||
|
||||
@@ -3,9 +3,10 @@ info:
|
||||
name: Test OpenCollection
|
||||
|
||||
extensions:
|
||||
ignore:
|
||||
- node_modules
|
||||
- .git
|
||||
bruno:
|
||||
ignore:
|
||||
- node_modules
|
||||
- .git
|
||||
|
||||
request:
|
||||
headers:
|
||||
|
||||
@@ -435,6 +435,8 @@ const populateRequestBody = ({ body, bodySchema, contentType }) => {
|
||||
* @returns {Object} Bruno example object
|
||||
*/
|
||||
const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName, exampleDescription, statusCode, contentType, requestBodySchema = null, requestBodyContentType = null }) => {
|
||||
const sanitized = String(exampleName ?? '').replace(/\r?\n/g, ' ').trim();
|
||||
const name = sanitized || `${statusCode} Response`;
|
||||
// Deep copy the body to avoid shared references
|
||||
const bodyCopy = {
|
||||
mode: brunoRequestItem.request.body.mode,
|
||||
@@ -449,7 +451,7 @@ const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName, examp
|
||||
const brunoExample = {
|
||||
uid: uuid(),
|
||||
itemUid: brunoRequestItem.uid,
|
||||
name: exampleName,
|
||||
name,
|
||||
description: exampleDescription,
|
||||
type: 'http-request',
|
||||
request: {
|
||||
|
||||
@@ -204,6 +204,9 @@ export interface BrunoConfig {
|
||||
passphrase?: string;
|
||||
}>;
|
||||
};
|
||||
scripts?: {
|
||||
additionalContextRoots?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface BrunoCollectionRoot {
|
||||
|
||||
@@ -572,7 +572,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
brunoRequestItem.examples = [];
|
||||
|
||||
i.response.forEach((response, responseIndex) => {
|
||||
const exampleName = response.name || `Example ${responseIndex + 1}`;
|
||||
const sanitized = String(response.name ?? '').replace(/\r?\n/g, ' ').trim();
|
||||
const exampleName = sanitized || `Example ${responseIndex + 1}`;
|
||||
|
||||
// Convert originalRequest to Bruno request format
|
||||
const originalRequest = response.originalRequest || {};
|
||||
|
||||
@@ -16,30 +16,22 @@ const {
|
||||
parseCollection,
|
||||
parseFolder
|
||||
} = require('@usebruno/filestore');
|
||||
const { parseDotEnv } = require('@usebruno/filestore');
|
||||
|
||||
const { uuid } = require('../utils/common');
|
||||
const { getRequestUid } = require('../cache/requestUids');
|
||||
const { decryptStringSafe } = require('../utils/encryption');
|
||||
const { setDotEnvVars } = require('../store/process-env');
|
||||
const { setBrunoConfig } = require('../store/bruno-config');
|
||||
const EnvironmentSecretsStore = require('../store/env-secrets');
|
||||
const UiStateSnapshot = require('../store/ui-state-snapshot');
|
||||
const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
|
||||
const { parseLargeRequestWithRedaction } = require('../utils/parse');
|
||||
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
|
||||
const dotEnvWatcher = require('./dotenv-watcher');
|
||||
|
||||
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
|
||||
|
||||
const environmentSecretsStore = new EnvironmentSecretsStore();
|
||||
|
||||
const isDotEnvFile = (pathname, collectionPath) => {
|
||||
const dirname = path.dirname(pathname);
|
||||
const basename = path.basename(pathname);
|
||||
|
||||
return dirname === collectionPath && basename === '.env';
|
||||
};
|
||||
|
||||
const isBrunoConfigFile = (pathname, collectionPath) => {
|
||||
const dirname = path.dirname(pathname);
|
||||
const basename = path.basename(pathname);
|
||||
@@ -227,24 +219,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
}
|
||||
}
|
||||
|
||||
if (isDotEnvFile(pathname, collectionPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(pathname, 'utf8');
|
||||
const jsonData = parseDotEnv(content);
|
||||
|
||||
setDotEnvVars(collectionUid, jsonData);
|
||||
const payload = {
|
||||
collectionUid,
|
||||
processEnvVariables: {
|
||||
...jsonData
|
||||
}
|
||||
};
|
||||
win.webContents.send('main:process-env-update', payload);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnvironmentsFolder(pathname, collectionPath)) {
|
||||
return addEnvironmentFile(win, pathname, collectionUid, collectionPath);
|
||||
}
|
||||
@@ -470,26 +444,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDotEnvFile(pathname, collectionPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(pathname, 'utf8');
|
||||
const jsonData = parseDotEnv(content);
|
||||
|
||||
setDotEnvVars(collectionUid, jsonData);
|
||||
const payload = {
|
||||
collectionUid,
|
||||
processEnvVariables: {
|
||||
...jsonData
|
||||
}
|
||||
};
|
||||
win.webContents.send('main:process-env-update', payload);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnvironmentsFolder(pathname, collectionPath)) {
|
||||
return changeEnvironmentFile(win, pathname, collectionUid, collectionPath);
|
||||
}
|
||||
@@ -759,6 +713,12 @@ class CollectionWatcher {
|
||||
ignored: (filepath) => {
|
||||
const normalizedPath = normalizeAndResolvePath(filepath);
|
||||
const relativePath = path.relative(watchPath, normalizedPath);
|
||||
const basename = path.basename(filepath);
|
||||
|
||||
// Ignore .env files - handled by dotenv-watcher
|
||||
if (basename === '.env' || basename.startsWith('.env.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any path segment matches a default ignore pattern (handles symlinks)
|
||||
const pathSegments = relativePath.split(path.sep);
|
||||
@@ -811,6 +771,8 @@ class CollectionWatcher {
|
||||
});
|
||||
|
||||
this.watchers[watchPath] = watcher;
|
||||
|
||||
dotEnvWatcher.addCollectionWatcher(win, watchPath, collectionUid);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -824,6 +786,8 @@ class CollectionWatcher {
|
||||
this.watchers[watchPath] = null;
|
||||
}
|
||||
|
||||
dotEnvWatcher.removeCollectionWatcher(watchPath);
|
||||
|
||||
const tempDirectoryPath = this.tempDirectoryMap[watchPath];
|
||||
if (tempDirectoryPath && this.watchers[tempDirectoryPath]) {
|
||||
this.watchers[tempDirectoryPath].close();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user