mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
233 Commits
feat/theme
...
fix/env-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de286ef50 | ||
|
|
689e0c6573 | ||
|
|
71227224dd | ||
|
|
dfa1533b72 | ||
|
|
cd33cb76fb | ||
|
|
0376d38860 | ||
|
|
6ea079f6b1 | ||
|
|
2fcfdfc338 | ||
|
|
09b8e8a32a | ||
|
|
d060544da6 | ||
|
|
d35394c714 | ||
|
|
3c585a30b7 | ||
|
|
ab2a16ac05 | ||
|
|
cb716e5978 | ||
|
|
c093354938 | ||
|
|
b0a88bf00c | ||
|
|
8b80166170 | ||
|
|
479fc160d7 | ||
|
|
2337d77092 | ||
|
|
2e58621759 | ||
|
|
78c629e7a6 | ||
|
|
03f7e60c66 | ||
|
|
540bb706e5 | ||
|
|
d8367e28ad | ||
|
|
5021226360 | ||
|
|
dfc3a1b78c | ||
|
|
634b62642f | ||
|
|
8724201148 | ||
|
|
f7cedcbd92 | ||
|
|
22ff82f57a | ||
|
|
f766ec2239 | ||
|
|
9e939a2188 | ||
|
|
471333fb80 | ||
|
|
1d126dcb65 | ||
|
|
0c3b828b09 | ||
|
|
e000e377d1 | ||
|
|
4e1123bd2d | ||
|
|
ac33c909ef | ||
|
|
53e158c6d1 | ||
|
|
3e581675cd | ||
|
|
e03cf9a519 | ||
|
|
91467f699c | ||
|
|
3871ca9edd | ||
|
|
2517fe078f | ||
|
|
7f047a4412 | ||
|
|
d30ab4d984 | ||
|
|
836c2b9ace | ||
|
|
e1827080dd | ||
|
|
ff87eb23ee | ||
|
|
7460078fd6 | ||
|
|
e4b6f7a28b | ||
|
|
bac51191ee | ||
|
|
6f4489a8f3 | ||
|
|
2d8c767b90 | ||
|
|
ccac391848 | ||
|
|
bff4da336a | ||
|
|
4c779da2d3 | ||
|
|
5d0a15121c | ||
|
|
215c9f9e8a | ||
|
|
828cb19048 | ||
|
|
a86f0e492f | ||
|
|
7d25d13436 | ||
|
|
00a59840fb | ||
|
|
ffa3509e8e | ||
|
|
82d93ec840 | ||
|
|
9127be8498 | ||
|
|
1d1c3d83ec | ||
|
|
aa2d7a120f | ||
|
|
20eb7b7277 | ||
|
|
37fbdec983 | ||
|
|
3b0370643a | ||
|
|
e3bf8f29b8 | ||
|
|
edee75e372 | ||
|
|
786326ae80 | ||
|
|
814663acb9 | ||
|
|
1c5e1c5fcf | ||
|
|
3c0d9ccd4c | ||
|
|
f07c93d613 | ||
|
|
319422c20f | ||
|
|
78240d9232 | ||
|
|
1443fb0f4e | ||
|
|
e6dd582a02 | ||
|
|
29e5ab95fe | ||
|
|
79ce71c040 | ||
|
|
15c2373fb0 | ||
|
|
27da99b817 | ||
|
|
ce01c69395 | ||
|
|
cdc3cb3bdf | ||
|
|
4de470525d | ||
|
|
798db041fa | ||
|
|
5672745b76 | ||
|
|
6e6804055d | ||
|
|
5904c36cdb | ||
|
|
8c997c46af | ||
|
|
700e25a1d5 | ||
|
|
c9059c9905 | ||
|
|
416b693afc | ||
|
|
bafb235e72 | ||
|
|
06dd5c14d5 | ||
|
|
0f0c2b5912 | ||
|
|
6664295b2b | ||
|
|
679cb91549 | ||
|
|
b9d9a27599 | ||
|
|
1fc703e4e3 | ||
|
|
89a0494e7e | ||
|
|
04806144a5 | ||
|
|
0c3d20b198 | ||
|
|
3ddf8e2a8b | ||
|
|
f10422cca6 | ||
|
|
ba166561cc | ||
|
|
3112380289 | ||
|
|
559946bcce | ||
|
|
e1c01ebe18 | ||
|
|
eb5dc12b43 | ||
|
|
a04ff3e819 | ||
|
|
5a6714f085 | ||
|
|
214e1434e5 | ||
|
|
3996b86bcb | ||
|
|
8fface4bbe | ||
|
|
b268aa9f98 | ||
|
|
df3ff5e48a | ||
|
|
6ca5c71f7a | ||
|
|
ca4d0dd40b | ||
|
|
4b724ebd85 | ||
|
|
b3a66e9c3c | ||
|
|
4f327b7b77 | ||
|
|
579cda1d1a | ||
|
|
61199fb966 | ||
|
|
79daf7700f | ||
|
|
6e34fbd0ce | ||
|
|
1fcf9ecc32 | ||
|
|
fec407e2eb | ||
|
|
5044241d17 | ||
|
|
584344ac47 | ||
|
|
d975d0b642 | ||
|
|
af6908e9c0 | ||
|
|
21673f46de | ||
|
|
51276beaf1 | ||
|
|
7661af34c8 | ||
|
|
01b87ee71c | ||
|
|
9e1c58ab6f | ||
|
|
0fb605a684 | ||
|
|
5fd3948028 | ||
|
|
3e92c44a5a | ||
|
|
67c1d39e60 | ||
|
|
2288121f70 | ||
|
|
a22eb43a27 | ||
|
|
27b7fa81f2 | ||
|
|
1f571267b0 | ||
|
|
0b79ce9095 | ||
|
|
75e17610f0 | ||
|
|
acf576872c | ||
|
|
c94785f521 | ||
|
|
154c45d87d | ||
|
|
0bf169562b | ||
|
|
967b073ded | ||
|
|
725dfeacac | ||
|
|
923d26ce56 | ||
|
|
7e258003d5 | ||
|
|
7689288763 | ||
|
|
81faa57808 | ||
|
|
bac9616de4 | ||
|
|
9ab1ed3d90 | ||
|
|
408c9d4a4e | ||
|
|
ebafdd813c | ||
|
|
6642f4d0b0 | ||
|
|
4f75474c87 | ||
|
|
e5b7aa5ab4 | ||
|
|
875df38501 | ||
|
|
a724f010ff | ||
|
|
f9423d1238 | ||
|
|
51e36519f7 | ||
|
|
bd0894ede0 | ||
|
|
b1e6a707bf | ||
|
|
c51381888a | ||
|
|
8b1b18cc39 | ||
|
|
707ed63be6 | ||
|
|
bc0bb64400 | ||
|
|
4c110900c1 | ||
|
|
65ed6d3cfb | ||
|
|
7b28b05bc1 | ||
|
|
b0f27d01b9 | ||
|
|
3351bf990a | ||
|
|
07fff423bb | ||
|
|
36d10ab480 | ||
|
|
c918c679d7 | ||
|
|
7e3386b1b8 | ||
|
|
f4162e1ce6 | ||
|
|
e6a48a73bf | ||
|
|
fceb99edc2 | ||
|
|
ebc105d42e | ||
|
|
32d56f6942 | ||
|
|
e4a1fca3b1 | ||
|
|
59ff9bdafb | ||
|
|
071ee9ab2e | ||
|
|
176646f983 | ||
|
|
d76a574c51 | ||
|
|
734ee16fe1 | ||
|
|
33594bdcec | ||
|
|
2acfe60a5f | ||
|
|
aecaab84dd | ||
|
|
45264bfcc5 | ||
|
|
b01b8d7bc4 | ||
|
|
58a38ac5a1 | ||
|
|
7328988e59 | ||
|
|
39a6fc837d | ||
|
|
5b1b1b5541 | ||
|
|
578fa72dc8 | ||
|
|
4708e8e589 | ||
|
|
2a9386ef6b | ||
|
|
9483dbf4af | ||
|
|
0b436e2c9f | ||
|
|
9005e17eb5 | ||
|
|
efe94d9c90 | ||
|
|
848825f16a | ||
|
|
4d60425a05 | ||
|
|
c03fe301f8 | ||
|
|
c8371410c2 | ||
|
|
dcbf38bf61 | ||
|
|
a57ecde1d0 | ||
|
|
791843174e | ||
|
|
1174f22d88 | ||
|
|
8300abe086 | ||
|
|
a3809ce4b9 | ||
|
|
adb46110dd | ||
|
|
7cc4c0993e | ||
|
|
1030d02ac7 | ||
|
|
d616be7271 | ||
|
|
afd49d146f | ||
|
|
97e43c4489 | ||
|
|
f9af22d586 | ||
|
|
8590bacd79 | ||
|
|
a7d1a349e3 |
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);
|
||||
120
.github/workflows/flaky-test-detector.yml
vendored
Normal file
120
.github/workflows/flaky-test-detector.yml
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
name: Flaky Test Detector
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tests/**/*.spec.ts'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: 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@v7
|
||||
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
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -58,6 +58,8 @@ jobs:
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
- name: Test Package bruno-electron
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
- name: Test Package bruno-requests
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,12 +48,15 @@ yarn-error.log*
|
||||
bruno.iml
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
# Development plan files
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
*.plan.md
|
||||
|
||||
# packages dist
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -66,7 +66,16 @@ Remember, these rules are here to make our codebase harmonious. If something doe
|
||||
|
||||
- Use styled component's theme prop to manage CSS colors and not CSS variables when in the context of a styled component or any react component using the styled component
|
||||
- Styled Components are used as wrappers to define both self and children components style, tailwind classes are used specifically for layout based styles.
|
||||
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
|
||||
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
|
||||
- MUST: Prefer custom hooks for business logic, data fetching, and side-effects.
|
||||
- MUST: Avoid `useEffect` unless absolutely needed. Prefer derived state, event handlers.
|
||||
- SHOULD: Memoize only when necessary (`useMemo`/`useCallback`), and prefer moving logic into hooks first.
|
||||
- MUST: Do not use namespace access for hooks in app code (e.g., `React.useCallback`, `React.useMemo`, `React.useState`). Import hooks directly.
|
||||
- Correct: `import { useCallback, useMemo, useState } from "react";`
|
||||
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
|
||||
- Add `data-testid` to testable elements for Playwright
|
||||
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
|
||||
|
||||
|
||||
## Readability and Abstractions
|
||||
|
||||
|
||||
BIN
assets/images/landing-2-dark.png
Normal file
BIN
assets/images/landing-2-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
BIN
assets/images/landing-2-light.png
Normal file
BIN
assets/images/landing-2-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 347 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 584 KiB |
@@ -3,7 +3,7 @@
|
||||
|
||||
### برونو - بيئة تطوير مفتوحة المصدر لاستكشاف واختبار واجهات برمجة التطبيقات (APIs).
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### ব্রুনো - API অন্বেষণ এবং পরীক্ষা করার জন্য ওপেনসোর্স IDE।
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - 开源 IDE,用于探索和测试 API。
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Opensource IDE zum Erkunden und Testen von APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE de código abierto para explorar y probar APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE Opensource pour explorer et tester des APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Opensource IDE per esplorare e testare gli APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - API の検証・動作テストのためのオープンソース IDE.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### ბრუნო - ღია წყაროების IDE API-ების შესწავლისა და ტესტირებისათვის.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - API 탐색 및 테스트를 위한 오픈소스 IDE.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Open source IDE voor het verkennen en testen van API's.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Otwartoźródłowe IDE do eksploracji i testów APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE de código aberto para explorar e testar APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Mediu integrat de dezvoltare cu sursă deschisă pentru explorarea și testarea API-urilor.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE с открытым исходным кодом для изучения и тестирования API.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - API'leri keşfetmek ve test etmek için açık kaynaklı IDE.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE із відкритим кодом для тестування та дослідження API
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - 探索和測試 API 的開源 IDE 工具
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
1792
package-lock.json
generated
1792
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -36,6 +36,7 @@
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
@@ -59,7 +60,6 @@
|
||||
"dev:watch": "node ./scripts/dev-hot-reload.js",
|
||||
"dev:web": "npm run dev --workspace=packages/bruno-app",
|
||||
"build:web": "npm run build --workspace=packages/bruno-app",
|
||||
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
|
||||
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
|
||||
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
|
||||
"storybook": "npm run storybook --workspace=packages/bruno-app",
|
||||
@@ -78,12 +78,12 @@
|
||||
"build:electron:rpm": "./scripts/build-electron.sh rpm",
|
||||
"build:electron:snap": "./scripts/build-electron.sh snap",
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
|
||||
"lint": "node --max_old_space_size=4096 $(npx which eslint)",
|
||||
"lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"nano-staged": {
|
||||
@@ -100,6 +100,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1"
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -8,8 +8,6 @@
|
||||
"build": "rsbuild build -m production",
|
||||
"preview": "rsbuild preview",
|
||||
"test": "jest",
|
||||
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
|
||||
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"",
|
||||
"storybook": "storybook dev -p 6006 --config-dir storybook",
|
||||
"build-storybook": "storybook build --config-dir storybook"
|
||||
},
|
||||
@@ -69,7 +67,7 @@
|
||||
"polished": "^4.3.1",
|
||||
"posthog-node": "4.2.1",
|
||||
"prettier": "^2.7.1",
|
||||
"qs": "^6.11.0",
|
||||
"qs": "^6.14.1",
|
||||
"query-string": "^7.0.1",
|
||||
"react": "19.0.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
@@ -84,12 +82,13 @@
|
||||
"react-player": "^2.16.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-tooltip": "^5.5.2",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "5.17.12",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"system": "^2.0.1",
|
||||
"url": "^0.11.3",
|
||||
"xml-formatter": "^3.5.0",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,10 +4,11 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
import { focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
@@ -17,10 +18,11 @@ 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 { toTitleCase } from 'utils/common/index';
|
||||
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
|
||||
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const getOsClass = () => {
|
||||
if (isMacOS()) return 'os-mac';
|
||||
@@ -29,6 +31,12 @@ const getOsClass = () => {
|
||||
return 'os-other';
|
||||
};
|
||||
|
||||
// Helper to get display name for workspace
|
||||
export const getWorkspaceDisplayName = (name) => {
|
||||
if (!name) return 'Untitled Workspace';
|
||||
return name;
|
||||
};
|
||||
|
||||
const AppTitleBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
@@ -115,19 +123,22 @@ const AppTitleBar = () => {
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" {...props}>
|
||||
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
|
||||
<span data-testid="workspace-name" className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace?.name)}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
dispatch(showHomePage());
|
||||
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
|
||||
if (scratchCollectionUid) {
|
||||
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
|
||||
toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`);
|
||||
};
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
@@ -178,7 +189,7 @@ const AppTitleBar = () => {
|
||||
|
||||
return {
|
||||
id: workspace.uid,
|
||||
label: toTitleCase(workspace.name),
|
||||
label: getWorkspaceDisplayName(workspace.name),
|
||||
onClick: () => handleWorkspaceSwitch(workspace.uid),
|
||||
className: `workspace-item ${isActive ? 'active' : ''}`,
|
||||
rightSection: (
|
||||
@@ -190,11 +201,7 @@ const AppTitleBar = () => {
|
||||
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
size="sm"
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
{isPinned ? <IconPinned size={14} stroke={1.5} /> : <IconPin size={14} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
@@ -245,14 +252,10 @@ const AppTitleBar = () => {
|
||||
)}
|
||||
|
||||
<div className="titlebar-content">
|
||||
{/* Left section: Home + Workspace */}
|
||||
<div className="titlebar-left">
|
||||
<ActionIcon
|
||||
onClick={handleHomeClick}
|
||||
label="Home"
|
||||
size="lg"
|
||||
className="home-button"
|
||||
>
|
||||
{showWindowControls && <AppMenu />}
|
||||
|
||||
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* 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 } from 'utils/codemirror/autocomplete';
|
||||
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
@@ -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',
|
||||
@@ -111,8 +112,12 @@ export default class CodeEditor extends React.Component {
|
||||
: cm.replaceSelection(' ', 'end');
|
||||
},
|
||||
'Shift-Tab': 'indentLess',
|
||||
'Ctrl-Space': 'autocomplete',
|
||||
'Cmd-Space': 'autocomplete',
|
||||
'Ctrl-Space': (cm) => {
|
||||
showRootHints(cm, this.props.showHintsFor);
|
||||
},
|
||||
'Cmd-Space': (cm) => {
|
||||
showRootHints(cm, this.props.showHintsFor);
|
||||
},
|
||||
'Ctrl-Y': 'foldAll',
|
||||
'Cmd-Y': 'foldAll',
|
||||
'Ctrl-I': 'unfoldAll',
|
||||
@@ -228,10 +233,17 @@ export default class CodeEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
this.editor.setCursor(cursor);
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
@@ -305,6 +317,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;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -32,6 +33,22 @@ const Headers = ({ collection }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key === 'name') {
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!headerNameRegex.test(row.name)) {
|
||||
return 'Header name cannot contain spaces or newlines';
|
||||
}
|
||||
}
|
||||
if (key === 'value') {
|
||||
if (!row.value) return null;
|
||||
if (!headerValueRegex.test(row.value)) {
|
||||
return 'Header value cannot contain newlines';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -39,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}
|
||||
@@ -47,7 +64,7 @@ const Headers = ({ collection }) => {
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
placeholder={!value ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -55,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}
|
||||
@@ -63,7 +80,7 @@ const Headers = ({ collection }) => {
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -101,6 +118,7 @@ const Headers = ({ collection }) => {
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -6,20 +6,39 @@ import { updateCollectionRequestScript, updateCollectionResponseScript } from 'p
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const Script = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [activeTab, setActiveTab] = useState('pre-request');
|
||||
const preRequestEditorRef = useRef(null);
|
||||
const postResponseEditorRef = useRef(null);
|
||||
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
|
||||
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevCollectionUidRef = useRef(collection.uid);
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different collection
|
||||
useEffect(() => {
|
||||
if (prevCollectionUidRef.current !== collection.uid) {
|
||||
prevCollectionUidRef.current = collection.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [collection.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -55,6 +74,10 @@ const Script = ({ collection }) => {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
};
|
||||
|
||||
const items = flattenItems(collection.items || []);
|
||||
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
|
||||
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
@@ -63,8 +86,18 @@ const Script = ({ collection }) => {
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
|
||||
<TabsTrigger value="post-response">Post Response</TabsTrigger>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
{requestScript && requestScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post-response">
|
||||
Post Response
|
||||
{responseScript && responseScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2">
|
||||
|
||||
@@ -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') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
20
packages/bruno-app/src/components/ColorBadge/index.js
Normal file
20
packages/bruno-app/src/components/ColorBadge/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const ColorBadge = ({ color, size = 10 }) => {
|
||||
const sizeValue = typeof size === 'string' ? size : `${size}px`;
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
width: sizeValue,
|
||||
height: sizeValue,
|
||||
backgroundColor: color || 'transparent'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorBadge;
|
||||
@@ -1,5 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div``;
|
||||
const StyledWrapper = styled.div`
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
164
packages/bruno-app/src/components/ColorPicker/index.js
Normal file
164
packages/bruno-app/src/components/ColorPicker/index.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { IconBan, IconBrush } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { parseToRgb, toColorString } from 'polished';
|
||||
import ColorRangePicker from 'components/ColorRange/index';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#CE4F3B',
|
||||
'#2E8A54',
|
||||
'#346AB2',
|
||||
'#C77A0F',
|
||||
'#B83D7F',
|
||||
'#8D44B2'
|
||||
];
|
||||
|
||||
const COLOR_RANGE_SEQUENCE = ['#D85D43', '#F4BB74', '#61DCB1', '#7EBDF2', '#D48ADE', '#B491E5'];
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
* @returns {red:string,green:string,blue:string}
|
||||
*/
|
||||
const hexToRgb = (hex) => {
|
||||
try {
|
||||
return parseToRgb(hex);
|
||||
} catch (err) {
|
||||
return { red: 0, green: 0, blue: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
const rgbToHex = (r, g, b) => {
|
||||
return toColorString({ red: Math.round(r), green: Math.round(g), blue: Math.round(b) });
|
||||
};
|
||||
|
||||
const interpolateColor = (position) => {
|
||||
const numColors = COLOR_RANGE_SEQUENCE.length;
|
||||
const scaledPos = (position / 100) * (numColors - 1);
|
||||
const index = Math.floor(scaledPos);
|
||||
const fraction = scaledPos - index;
|
||||
|
||||
if (index >= numColors - 1) {
|
||||
return COLOR_RANGE_SEQUENCE[numColors - 1];
|
||||
}
|
||||
|
||||
const color1 = hexToRgb(COLOR_RANGE_SEQUENCE[index]);
|
||||
const color2 = hexToRgb(COLOR_RANGE_SEQUENCE[index + 1]);
|
||||
|
||||
const r = color1.red + (color2.red - color1.red) * fraction;
|
||||
const g = color1.green + (color2.green - color1.green) * fraction;
|
||||
const b = color1.blue + (color2.blue - color1.blue) * fraction;
|
||||
|
||||
return rgbToHex(r, g, b);
|
||||
};
|
||||
|
||||
const findClosestPosition = (hex) => {
|
||||
if (!hex) return 0;
|
||||
const target = hexToRgb(hex);
|
||||
let closestPos = 0;
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (let pos = 0; pos <= 100; pos++) {
|
||||
const color = hexToRgb(interpolateColor(pos));
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(target.red - color.red, 2) + Math.pow(target.green - color.green, 2) + Math.pow(target.blue - color.blue, 2)
|
||||
);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestPos = pos;
|
||||
}
|
||||
}
|
||||
return closestPos;
|
||||
};
|
||||
|
||||
const ColorPickerIcon = ({ color }) => {
|
||||
if (color) {
|
||||
return <ColorBadge color={color} size={8} />;
|
||||
}
|
||||
return <IconBrush size={14} strokeWidth={1.5} className="opacity-70" />;
|
||||
};
|
||||
|
||||
const ColorPicker = ({ color, onChange, icon }) => {
|
||||
const [sliderPosition, setSliderPosition] = useState(() =>
|
||||
color && !PRESET_COLORS.includes(color) ? findClosestPosition(color) : 0
|
||||
);
|
||||
const [customColor, setCustomColor] = useState(() =>
|
||||
color && !PRESET_COLORS.includes(color) ? color : COLOR_RANGE_SEQUENCE[0]
|
||||
);
|
||||
const pendingColorRef = useRef(customColor);
|
||||
|
||||
const handleColorSelect = (selectedColor) => {
|
||||
onChange(selectedColor);
|
||||
};
|
||||
|
||||
const handleSliderChange = (e) => {
|
||||
const newPosition = parseInt(e.target.value, 10);
|
||||
setSliderPosition(newPosition);
|
||||
const newColor = interpolateColor(newPosition);
|
||||
setCustomColor(newColor);
|
||||
pendingColorRef.current = newColor;
|
||||
};
|
||||
|
||||
const handleSliderEnd = () => {
|
||||
onChange(pendingColorRef.current);
|
||||
};
|
||||
|
||||
const defaultIcon = (
|
||||
<div className="cursor-pointer flex items-center" title="Change color">
|
||||
<ColorPickerIcon color={color} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const colorPickerContent = (
|
||||
<StyledWrapper>
|
||||
<div className="p-2">
|
||||
<div className="flex flex-wrap gap-1.5 justify-between items-center">
|
||||
<div
|
||||
className="w-5 h-5 cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110"
|
||||
onClick={() => handleColorSelect(null)}
|
||||
title="No color"
|
||||
>
|
||||
<IconBan size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
{PRESET_COLORS.map((presetColor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-5 h-5 rounded cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110 border-2 border-transparent
|
||||
${color === presetColor ? 'border-solid !border-current' : ''}
|
||||
`}
|
||||
style={{ backgroundColor: presetColor }}
|
||||
onClick={() => handleColorSelect(presetColor)}
|
||||
title={presetColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 pt-0.5">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 cursor-pointer"
|
||||
style={{ backgroundColor: customColor }}
|
||||
onClick={() => handleColorSelect(customColor)}
|
||||
title="Custom color"
|
||||
/>
|
||||
<ColorRangePicker
|
||||
className="flex-1 flex"
|
||||
value={sliderPosition}
|
||||
onChange={handleSliderChange}
|
||||
onMouseUp={handleSliderEnd}
|
||||
selectedColor={customColor}
|
||||
colorRange={COLOR_RANGE_SEQUENCE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown icon={icon || defaultIcon} placement="bottom-start">
|
||||
{colorPickerContent}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -0,0 +1,46 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.hue-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hue-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.color ?? props.theme.bg};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.hue-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.hue-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.color ?? props.theme.bg};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.hue-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
23
packages/bruno-app/src/components/ColorRange/index.js
Normal file
23
packages/bruno-app/src/components/ColorRange/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => {
|
||||
return (
|
||||
<StyledWrapper color={selectedColor} className={className}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="hue-slider"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${colorRange.join(',')})`
|
||||
}}
|
||||
title="Adjust color"
|
||||
{...props}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorRangePicker;
|
||||
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { IconPlus, IconApi, IconBrandGraphql, IconPlugConnected, IconCode } from '@tabler/icons';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
|
||||
import filter from 'lodash/filter';
|
||||
import { get } from 'lodash';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const REQUEST_TYPE = {
|
||||
HTTP: 'http',
|
||||
GRAPHQL: 'graphql',
|
||||
GRPC: 'grpc',
|
||||
WEBSOCKET: 'websocket'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a request name for transient requests in the pattern "Untitled {Count}"
|
||||
* @param {Object} collection - The collection object
|
||||
* @returns {string} A request name like "Untitled 1", "Untitled 2", etc.
|
||||
*/
|
||||
const generateTransientRequestName = (collection) => {
|
||||
if (!collection || !collection.items) {
|
||||
return 'Untitled 1';
|
||||
}
|
||||
const allItems = flattenItems(collection.items);
|
||||
const transientRequests = filter(allItems, (item) => {
|
||||
return isItemTransientRequest(item);
|
||||
});
|
||||
|
||||
// Find the highest "Untitled X" number among transient requests
|
||||
let maxNumber = 0;
|
||||
transientRequests.forEach((item) => {
|
||||
const match = item.name?.match(/^Untitled (\d+)$/);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Increment from the highest number found, or start at 1 if none found
|
||||
const count = maxNumber + 1;
|
||||
|
||||
return `Untitled ${count}`;
|
||||
};
|
||||
|
||||
const CreateTransientRequest = ({ collectionUid }) => {
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections?.find((c) => c.uid === collectionUid);
|
||||
}, [collections, collectionUid]);
|
||||
|
||||
const collectionPresets = useMemo(() => {
|
||||
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
|
||||
requestType: 'http',
|
||||
requestUrl: ''
|
||||
});
|
||||
}, [collection]);
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
if (ref) {
|
||||
ref.setProps({
|
||||
onHide: () => {
|
||||
setDropdownVisible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeftClick = () => {
|
||||
handleItemClick(collectionPresets.requestType);
|
||||
};
|
||||
|
||||
const handleRightClick = (e) => {
|
||||
e.preventDefault();
|
||||
setDropdownVisible(true);
|
||||
};
|
||||
|
||||
const handleCreateHttpRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'http-request',
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
requestMethod: 'GET',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGraphQLRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'graphql-request',
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
requestMethod: 'POST',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true,
|
||||
body: {
|
||||
mode: 'graphql',
|
||||
graphql: {
|
||||
query: '',
|
||||
variables: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateWebSocketRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newWsRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
requestMethod: 'ws',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGrpcRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newGrpcRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleItemClick = (type) => {
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.hide();
|
||||
}
|
||||
switch (type) {
|
||||
case REQUEST_TYPE.HTTP:
|
||||
handleCreateHttpRequest();
|
||||
break;
|
||||
case REQUEST_TYPE.GRAPHQL:
|
||||
handleCreateGraphQLRequest();
|
||||
break;
|
||||
case REQUEST_TYPE.GRPC:
|
||||
handleCreateGrpcRequest();
|
||||
break;
|
||||
case REQUEST_TYPE.WEBSOCKET:
|
||||
handleCreateWebSocketRequest();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const IconButton = (
|
||||
<ActionIcon
|
||||
onClick={handleLeftClick}
|
||||
onContextMenu={handleRightClick}
|
||||
aria-label="New Transient Request"
|
||||
size="lg"
|
||||
style={{ marginBottom: '3px' }}
|
||||
>
|
||||
<IconPlus size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
icon={IconButton}
|
||||
visible={dropdownVisible}
|
||||
onCreate={onDropdownCreate}
|
||||
onClickOutside={() => setDropdownVisible(false)}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.HTTP)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconApi size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">HTTP</div>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRAPHQL)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconBrandGraphql size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">GraphQL</div>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRPC)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconCode size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">gRPC</div>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.WEBSOCKET)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconPlugConnected size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">WebSocket</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTransientRequest;
|
||||
@@ -259,10 +259,6 @@ const StyledWrapper = styled.div`
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
pre {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.w-full.h-full.relative.flex {
|
||||
height: 100% !important;
|
||||
@@ -321,7 +317,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100% !important;
|
||||
max-height: 400px !important;
|
||||
padding: 0.5rem !important;
|
||||
|
||||
|
||||
.network-logs-pre {
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
font-size: ${(props) => props.theme.font.size.xs} !important;
|
||||
|
||||
@@ -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) {
|
||||
@@ -174,6 +180,7 @@ const openTerminalIntoContainer = async (container, sessionId) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
try {
|
||||
fitAddon.fit();
|
||||
terminal.focus();
|
||||
const { cols, rows } = terminal;
|
||||
if (cols && rows && window.ipcRenderer) {
|
||||
window.ipcRenderer.send('terminal:resize', sessionId, { cols, rows });
|
||||
@@ -211,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) => {
|
||||
@@ -354,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;
|
||||
@@ -361,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);
|
||||
|
||||
@@ -64,6 +64,89 @@ const LogTimestamp = ({ timestamp }) => {
|
||||
return <span className="log-timestamp">{time}</span>;
|
||||
};
|
||||
|
||||
// Helper function to check if an object is a plain object (not a class instance)
|
||||
const isPlainObject = (obj) => {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
return proto === null || proto === Object.prototype;
|
||||
};
|
||||
|
||||
// Helper function to transform Bruno special types back to readable format
|
||||
// Extracted outside component to avoid recreation on every render
|
||||
const transformBrunoTypes = (obj, seen = new WeakSet()) => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Guard against circular references
|
||||
if (seen.has(obj)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(obj);
|
||||
|
||||
// Handle Bruno special types
|
||||
if (obj.__brunoType) {
|
||||
switch (obj.__brunoType) {
|
||||
case 'Set':
|
||||
// Transform Set to display values at top level with numeric indices
|
||||
if (Array.isArray(obj.__brunoValue)) {
|
||||
return Object.fromEntries(
|
||||
obj.__brunoValue.map((value, index) => [index, transformBrunoTypes(value, seen)])
|
||||
);
|
||||
}
|
||||
return {};
|
||||
case 'Map':
|
||||
// Transform Map to display entries at top level with => notation
|
||||
if (Array.isArray(obj.__brunoValue)) {
|
||||
const mapEntries = {};
|
||||
for (const entry of obj.__brunoValue) {
|
||||
// Defensive check: ensure entry is a valid [key, value] pair
|
||||
if (Array.isArray(entry) && entry.length >= 2) {
|
||||
const [key, value] = entry;
|
||||
mapEntries[`${String(key)} =>`] = transformBrunoTypes(value, seen);
|
||||
}
|
||||
}
|
||||
return mapEntries;
|
||||
}
|
||||
return {};
|
||||
case 'Function':
|
||||
return `[Function: ${obj.__brunoValue?.split?.('\n')?.[0]?.substring(0, 50) ?? 'anonymous'}...]`;
|
||||
case 'undefined':
|
||||
return 'undefined';
|
||||
default:
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrays - recurse into elements
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => transformBrunoTypes(item, seen));
|
||||
}
|
||||
|
||||
// Preserve non-plain objects (Date, Error, RegExp, class instances, etc.)
|
||||
if (!isPlainObject(obj)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Only deep-clone plain objects
|
||||
const transformed = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
transformed[key] = transformBrunoTypes(value, seen);
|
||||
}
|
||||
return transformed;
|
||||
};
|
||||
|
||||
// Helper to get metadata about Bruno types for display purposes
|
||||
const getBrunoTypeMetadata = (obj) => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return {};
|
||||
}
|
||||
if (obj.__brunoType === 'Set' || obj.__brunoType === 'Map') {
|
||||
return { type: obj.__brunoType };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const LogMessage = ({ message, args }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
@@ -71,18 +154,30 @@ const LogMessage = ({ message, args }) => {
|
||||
if (originalArgs && originalArgs.length > 0) {
|
||||
return originalArgs.map((arg, index) => {
|
||||
if (typeof arg === 'object' && arg !== null) {
|
||||
const metadata = getBrunoTypeMetadata(arg);
|
||||
const transformedArg = transformBrunoTypes(arg);
|
||||
|
||||
// Determine the name to display based on the type
|
||||
let displayName = false;
|
||||
let shouldCollapse = 1; // Default: collapse at depth 1 for regular objects
|
||||
|
||||
if (metadata.type === 'Map' || metadata.type === 'Set') {
|
||||
displayName = metadata.type;
|
||||
shouldCollapse = true; // Fully collapse Maps/Sets by default
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="log-object">
|
||||
<ReactJson
|
||||
src={arg}
|
||||
src={transformedArg}
|
||||
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
iconStyle="triangle"
|
||||
indentWidth={2}
|
||||
collapsed={1}
|
||||
collapsed={shouldCollapse}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={false}
|
||||
enableClipboard={false}
|
||||
name={false}
|
||||
name={displayName}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '${(props) => props.theme.font.size.sm}',
|
||||
|
||||
@@ -85,6 +85,17 @@ const Wrapper = styled.div`
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropdown-tab-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: ${(props) => props.theme.dropdown.hoverBg};
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(.disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
@@ -173,6 +184,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +129,7 @@ const StyledWrapper = styled.div`
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
text-overflow: clip;
|
||||
|
||||
input[type='checkbox'] {
|
||||
vertical-align: baseline;
|
||||
@@ -84,6 +139,9 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.tooltip-mod {
|
||||
max-width: 200px !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
|
||||
@@ -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) => {
|
||||
@@ -162,15 +252,17 @@ const EditableTable = ({
|
||||
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
if (!movedRow) {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
@@ -179,15 +271,34 @@ const EditableTable = ({
|
||||
const value = column.getValue ? column.getValue(row) : row[column.key];
|
||||
const error = getRowError?.(row, rowIndex, column.key);
|
||||
|
||||
const errorIcon = error && !isEmpty ? (
|
||||
<span>
|
||||
<IconAlertCircle
|
||||
data-tooltip-id={`error-${row.uid}-${column.key}`}
|
||||
className="text-red-600 cursor-pointer ml-1"
|
||||
size={20}
|
||||
/>
|
||||
<Tooltip
|
||||
className="tooltip-mod"
|
||||
id={`error-${row.uid}-${column.key}`}
|
||||
html={error}
|
||||
/>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (column.render) {
|
||||
return column.render({
|
||||
row,
|
||||
value,
|
||||
rowIndex,
|
||||
isLastEmptyRow: isEmpty,
|
||||
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue),
|
||||
error
|
||||
});
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{column.render({
|
||||
row,
|
||||
value,
|
||||
rowIndex,
|
||||
isLastEmptyRow: isEmpty,
|
||||
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue)
|
||||
})}
|
||||
{errorIcon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -201,23 +312,10 @@ 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)}
|
||||
/>
|
||||
{error && !isEmpty && (
|
||||
<span>
|
||||
<IconAlertCircle
|
||||
data-tooltip-id={`error-${row.uid}-${column.key}`}
|
||||
className="text-red-600 cursor-pointer"
|
||||
size={20}
|
||||
/>
|
||||
<Tooltip
|
||||
className="tooltip-mod"
|
||||
id={`error-${row.uid}-${column.key}`}
|
||||
html={error}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{errorIcon}
|
||||
</div>
|
||||
);
|
||||
}, [isLastEmptyRow, getRowError, handleValueChange]);
|
||||
@@ -225,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>
|
||||
@@ -233,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,558 @@
|
||||
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';
|
||||
import { stripEnvVarUid } from 'utils/environments';
|
||||
|
||||
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 + 2);
|
||||
}, []);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
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;
|
||||
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
|
||||
|
||||
prevEnvUidRef.current = environment.uid;
|
||||
prevEnvVariablesRef.current = environment.variables;
|
||||
mountedRef.current = true;
|
||||
|
||||
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
|
||||
formik.setValues([
|
||||
...draft.variables,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
|
||||
}, [environment.variables]);
|
||||
|
||||
// Sync modified state
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
|
||||
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.map(stripEnvVarUid));
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
|
||||
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : 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 || [];
|
||||
|
||||
// Compare without UIDs since they can be different but the actual data is the same
|
||||
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
|
||||
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={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { 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() === '') ? 'Name' : ''}
|
||||
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);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
if (variable.ephemeral) {
|
||||
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
|
||||
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
|
||||
}
|
||||
}}
|
||||
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;
|
||||
@@ -46,8 +46,8 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn
|
||||
let importedCount = 0;
|
||||
for (const environment of validEnvironments) {
|
||||
const action = isGlobal
|
||||
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
|
||||
? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid });
|
||||
|
||||
await dispatch(action);
|
||||
importedCount++;
|
||||
|
||||
@@ -4,7 +4,14 @@ import Modal from 'components/Modal';
|
||||
import Portal from 'components/Portal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
|
||||
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal, isDotEnv }) => {
|
||||
let settingsLabel = 'collection environment settings';
|
||||
if (isDotEnv) {
|
||||
settingsLabel = '.env file';
|
||||
} else if (isGlobal) {
|
||||
settingsLabel = 'global environment settings';
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
@@ -21,7 +28,7 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
|
||||
<h1 className="ml-2 text-lg font-medium">Hold on...</h1>
|
||||
</div>
|
||||
<div className="font-normal mt-4">
|
||||
You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings.
|
||||
You have unsaved changes in {settingsLabel}.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
|
||||
@@ -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 + 2);
|
||||
}, [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,344 @@
|
||||
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);
|
||||
window.dispatchEvent(new Event('dotenv-save-complete'));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
window.dispatchEvent(new Event('dotenv-save-failed'));
|
||||
})
|
||||
.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);
|
||||
window.dispatchEvent(new Event('dotenv-save-complete'));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
window.dispatchEvent(new Event('dotenv-save-failed'));
|
||||
})
|
||||
.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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
|
||||
const EnvironmentListContent = ({
|
||||
environments,
|
||||
@@ -38,6 +39,7 @@ const EnvironmentListContent = ({
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="max-w-100% truncate no-wrap">{env.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -33,8 +33,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
.env-separator {
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
|
||||
margin: 0 0.35rem;
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
|
||||
}
|
||||
|
||||
.env-text-inactive {
|
||||
|
||||
@@ -13,6 +13,166 @@ import ImportEnvironmentModal from 'components/Environments/Common/ImportEnviron
|
||||
import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { transparentize, toColorString, parseToRgb } from 'polished';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
|
||||
const EMPTY_STATE_DESCRIPTIONS = {
|
||||
collection: 'Create your first environment to begin working with your collection.',
|
||||
global: 'Create your first global environment to begin working across collections.'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates background color with transparency for environment badges
|
||||
*/
|
||||
const getEnvBackgroundColor = (color) => (color ? transparentize(1 - 0.12, color) : 'transparent');
|
||||
|
||||
/**
|
||||
* Calculates the style for an environment badge section
|
||||
*/
|
||||
const getEnvBadgeStyle = (environment, position, hasOtherEnv) => {
|
||||
const color = environment?.color;
|
||||
const isLeft = position === 'left';
|
||||
|
||||
// Determine border radius based on position and whether other env exists
|
||||
let borderRadius = '0.3rem';
|
||||
if (hasOtherEnv) {
|
||||
borderRadius = isLeft ? '0.3rem 0 0 0.3rem' : '0 0.3rem 0.3rem 0';
|
||||
}
|
||||
|
||||
// Determine padding based on position
|
||||
const padding = isLeft
|
||||
? hasOtherEnv
|
||||
? '0.25rem 0.5rem 0.25rem 0.5rem'
|
||||
: '0.25rem 0.3rem 0.25rem 0.5rem'
|
||||
: '0.25rem 0.3rem 0.25rem 0.5rem';
|
||||
|
||||
return {
|
||||
backgroundColor: getEnvBackgroundColor(color),
|
||||
padding,
|
||||
borderRadius
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates dropdown width based on longest environment name
|
||||
*/
|
||||
const calculateDropdownWidth = (environments, globalEnvironments) => {
|
||||
const allEnvironments = [...environments, ...globalEnvironments];
|
||||
if (allEnvironments.length === 0) return 0;
|
||||
|
||||
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
|
||||
// 8 pixels per character (rough estimate for average character width)
|
||||
return maxCharLength * 8;
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a single environment with icon, name, and optional color styling
|
||||
*/
|
||||
const EnvironmentBadge = ({ environment, icon: Icon }) => {
|
||||
if (!environment) return null;
|
||||
|
||||
const colorStyle = environment.color ? { color: environment.color } : {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Icon size={14} strokeWidth={1.5} className="env-icon" style={colorStyle} />
|
||||
<ToolHint
|
||||
text={environment.name}
|
||||
toolhintId={`env-${environment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={environment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden" style={colorStyle}>
|
||||
{environment.name}
|
||||
</span>
|
||||
</ToolHint>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dropdown trigger component showing active environments
|
||||
*/
|
||||
const DropdownTrigger = forwardRef(({ collectionEnv, globalEnv }, ref) => {
|
||||
const hasAnyEnv = collectionEnv || globalEnv;
|
||||
|
||||
// Empty state - no environments selected
|
||||
if (!hasAnyEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent no-environments"
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only collection env selected - caret goes with collection env
|
||||
if (collectionEnv && !globalEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', false)}>
|
||||
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only global env selected - caret goes with global env
|
||||
if (!collectionEnv && globalEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', false)}>
|
||||
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Both environments selected
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{/* Collection Environment Section */}
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', true)}>
|
||||
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="env-separator" style={{ width: '1px', alignSelf: 'stretch' }} />
|
||||
|
||||
{/* Global Environment Section + Caret */}
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', true)}>
|
||||
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -35,159 +195,82 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
? find(environments, (e) => e.uid === activeEnvironmentUid)
|
||||
: null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
const dropdownWidth = useMemo(
|
||||
() => calculateDropdownWidth(environments, globalEnvironments),
|
||||
[environments, globalEnvironments]
|
||||
);
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
const description = EMPTY_STATE_DESCRIPTIONS[activeTab];
|
||||
|
||||
// Get description based on active tab
|
||||
const description
|
||||
= activeTab === 'collection'
|
||||
? 'Create your first environment to begin working with your collection.'
|
||||
: 'Create your first global environment to begin working across collections.';
|
||||
const hideDropdown = () => dropdownTippyRef.current?.hide();
|
||||
|
||||
// Environment selection handler
|
||||
const handleEnvironmentSelect = (environment) => {
|
||||
const action
|
||||
= activeTab === 'collection'
|
||||
? selectEnvironment(environment ? environment.uid : null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
|
||||
? selectEnvironment(environment?.uid || null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment?.uid || null });
|
||||
|
||||
dispatch(action)
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success('No Environments are active now');
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
toast.success(environment ? `Environment changed to ${environment.name}` : 'No Environments are active now');
|
||||
hideDropdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while selecting the environment');
|
||||
});
|
||||
};
|
||||
|
||||
// Settings handler - opens environment settings tab
|
||||
const handleSettingsClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
const isCollection = activeTab === 'collection';
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-${isCollection ? 'environment' : 'global-environment'}-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: isCollection ? 'environment-settings' : 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
hideDropdown();
|
||||
};
|
||||
|
||||
// Create handler
|
||||
const handleCreateClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowCreateCollectionModal(true);
|
||||
} else {
|
||||
setShowCreateGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
hideDropdown();
|
||||
};
|
||||
|
||||
// Import handler
|
||||
const handleImportClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowImportCollectionModal(true);
|
||||
} else {
|
||||
setShowImportGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
hideDropdown();
|
||||
};
|
||||
|
||||
// Calculate dropdown width based on the longest environment name.
|
||||
// To prevent resizing while switching between collection and global environments.
|
||||
const dropdownWidth = useMemo(() => {
|
||||
const allEnvironments = [...environments, ...globalEnvironments];
|
||||
if (allEnvironments.length === 0) return 0;
|
||||
|
||||
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
|
||||
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
|
||||
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
|
||||
return maxCharLength * 8;
|
||||
}, [environments, globalEnvironments]);
|
||||
|
||||
// Create icon component for dropdown trigger
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
|
||||
|
||||
const displayContent = hasAnyEnv ? (
|
||||
<>
|
||||
{activeCollectionEnvironment && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeCollectionEnvironment.name}
|
||||
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeCollectionEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
{activeGlobalEnvironment && <span className="env-separator">|</span>}
|
||||
</>
|
||||
)}
|
||||
{activeGlobalEnvironment && (
|
||||
<div className="flex items-center">
|
||||
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeGlobalEnvironment.name}
|
||||
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeGlobalEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
const openEnvironmentSettingsTab = (type) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-${type}-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: `${type}-settings`
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
|
||||
!hasAnyEnv ? 'no-environments' : ''
|
||||
}`}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{displayContent}
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper width={dropdownWidth}>
|
||||
<div className="environment-selector flex align-center cursor-pointer">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<Dropdown
|
||||
onCreate={(ref) => (dropdownTippyRef.current = ref)}
|
||||
icon={<DropdownTrigger collectionEnv={activeCollectionEnvironment} globalEnv={activeGlobalEnvironment} />}
|
||||
placement="bottom-end"
|
||||
>
|
||||
{/* Tab Headers */}
|
||||
<div className="tab-header flex pt-3 pb-2 px-3">
|
||||
{tabs.map((tab) => (
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
|
||||
@@ -222,15 +305,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -238,15 +313,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
<ImportEnvironmentModal
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -254,15 +321,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
<CreateEnvironment
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -271,15 +330,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
type="collection"
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -59,7 +59,7 @@ const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
size="md"
|
||||
title="Create Environment"
|
||||
confirmText="Create"
|
||||
handleConfirm={onSubmit}
|
||||
|
||||
@@ -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,42 +1,20 @@
|
||||
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
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 EnvironmentVariables = ({ environment, setIsModified, collection }) => {
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const environmentsDraft = collection?.environmentsDraft;
|
||||
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
|
||||
|
||||
// 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 = {};
|
||||
@@ -82,425 +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>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formik.values.map((variable, index) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
|
||||
<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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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,12 +1,14 @@
|
||||
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 { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
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';
|
||||
import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment';
|
||||
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
@@ -18,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 +117,27 @@ 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));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openDeleteModal && (
|
||||
@@ -157,11 +184,46 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
<ColorPicker color={environment.color} onChange={handleColorChange} />
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
@@ -175,7 +237,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,17 +99,45 @@ 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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 13px;
|
||||
@@ -191,9 +219,12 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -210,12 +241,14 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.creating {
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -232,6 +265,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,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,16 +1,33 @@
|
||||
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 { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
const EnvironmentList = ({
|
||||
environments,
|
||||
@@ -24,7 +41,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);
|
||||
@@ -37,10 +53,53 @@ 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);
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
dispatch(setEnvironmentsDraft({
|
||||
collectionUid: collection.uid,
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
setSelectedDotEnvFile(null);
|
||||
setActiveView('environment');
|
||||
handleDotEnvModifiedChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile);
|
||||
if (!selectedDotEnvFile || !fileExists) {
|
||||
setSelectedDotEnvFile(dotEnvFiles[0].filename);
|
||||
}
|
||||
}, [dotEnvFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environments?.length) {
|
||||
setSelectedEnvironment(null);
|
||||
@@ -86,44 +145,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);
|
||||
@@ -134,7 +183,7 @@ const EnvironmentList = ({
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleActivateEnvironment = (e, env) => {
|
||||
const handleActivateEnvironment = useCallback((e, env) => {
|
||||
e.stopPropagation();
|
||||
dispatch(selectEnvironment(env.uid, collection.uid))
|
||||
.then(() => {
|
||||
@@ -143,11 +192,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() === '') {
|
||||
@@ -170,7 +215,7 @@ const EnvironmentList = ({
|
||||
};
|
||||
|
||||
const handleCreateEnvClick = () => {
|
||||
if (!isModified) {
|
||||
if (!isModified && !isDotEnvModified) {
|
||||
setIsCreatingInline(true);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
@@ -182,11 +227,13 @@ const EnvironmentList = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
const handleCancelCreate = useCallback(() => {
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
|
||||
|
||||
const handleSaveNewEnv = () => {
|
||||
const error = validateEnvironmentName(newEnvName);
|
||||
@@ -253,14 +300,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);
|
||||
@@ -279,12 +328,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!`);
|
||||
handleDotEnvModifiedChange(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={handleDotEnvModifiedChange}
|
||||
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)} />
|
||||
)}
|
||||
@@ -298,42 +532,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"
|
||||
@@ -341,6 +644,7 @@ const EnvironmentList = ({
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
@@ -349,7 +653,7 @@ const EnvironmentList = ({
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
@@ -357,7 +661,7 @@ const EnvironmentList = ({
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
@@ -365,75 +669,94 @@ const EnvironmentList = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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;
|
||||
|
||||
@@ -34,23 +34,36 @@ class ErrorBoundary extends Component {
|
||||
|
||||
const serializeArgs = (args) => {
|
||||
return args.map((arg) => {
|
||||
const seen = new WeakSet();
|
||||
|
||||
const replacer = (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
|
||||
const error = {};
|
||||
Object.getOwnPropertyNames(value).forEach((prop) => {
|
||||
error[prop] = value[prop];
|
||||
});
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
try {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
|
||||
return arg;
|
||||
}
|
||||
if (arg instanceof Error) {
|
||||
return {
|
||||
__type: 'Error',
|
||||
name: arg.name,
|
||||
message: arg.message,
|
||||
stack: arg.stack
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(arg));
|
||||
return JSON.parse(JSON.stringify(arg, replacer));
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
pre {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IpcErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<StyledWrapper>
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</Portal>
|
||||
</StyledWrapper>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpcErrorModal;
|
||||
@@ -3,7 +3,7 @@ import get from 'lodash/get';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
|
||||
import { updateFolderAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
|
||||
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
|
||||
@@ -20,7 +20,7 @@ import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder }) => {
|
||||
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const save = () => {
|
||||
@@ -90,6 +90,13 @@ const Auth = ({ collection, folder }) => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
|
||||
const updateFolderAuth = ({ itemUid, ...rest }) => {
|
||||
return _updateFolderAuth({
|
||||
...rest,
|
||||
folderUid: folder.uid
|
||||
});
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'basic': {
|
||||
@@ -178,7 +185,7 @@ const Auth = ({ collection, folder }) => {
|
||||
collection={collection}
|
||||
item={folder}
|
||||
/>
|
||||
<GrantTypeComponentMap collection={collection} folder={folder} />
|
||||
<GrantTypeComponentMap collection={collection} folder={folder} updateFolderAuth={updateFolderAuth} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -36,6 +37,22 @@ const Headers = ({ collection, folder }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key === 'name') {
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!headerNameRegex.test(row.name)) {
|
||||
return 'Header name cannot contain spaces or newlines';
|
||||
}
|
||||
}
|
||||
if (key === 'value') {
|
||||
if (!row.value) return null;
|
||||
if (!headerValueRegex.test(row.value)) {
|
||||
return 'Header value cannot contain newlines';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -43,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}
|
||||
@@ -51,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' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -59,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}
|
||||
@@ -68,7 +85,7 @@ const Headers = ({ collection, folder }) => {
|
||||
collection={collection}
|
||||
item={folder}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -106,6 +123,7 @@ const Headers = ({ collection, folder }) => {
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -6,20 +6,39 @@ import { updateFolderRequestScript, updateFolderResponseScript } from 'providers
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const Script = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [activeTab, setActiveTab] = useState('pre-request');
|
||||
const preRequestEditorRef = useRef(null);
|
||||
const postResponseEditorRef = useRef(null);
|
||||
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
|
||||
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevFolderUidRef = useRef(folder.uid);
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different folder
|
||||
useEffect(() => {
|
||||
if (prevFolderUidRef.current !== folder.uid) {
|
||||
prevFolderUidRef.current = folder.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [folder.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -57,6 +76,10 @@ const Script = ({ collection, folder }) => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
|
||||
const items = flattenItems(folder.items || []);
|
||||
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
|
||||
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
@@ -65,8 +88,18 @@ const Script = ({ collection, folder }) => {
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
|
||||
<TabsTrigger value="post-response">Post Response</TabsTrigger>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
{requestScript && requestScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post-response">
|
||||
Post Response
|
||||
{responseScript && responseScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2">
|
||||
|
||||
@@ -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') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal/index';
|
||||
|
||||
const getOSName = () => {
|
||||
const platform = window.navigator.userAgentData?.platform || '';
|
||||
if (platform.startsWith('Win')) {
|
||||
return 'Windows';
|
||||
} else if (platform.startsWith('Mac')) {
|
||||
return 'macOS';
|
||||
} else if (platform.startsWith('Linux')) {
|
||||
return 'Linux';
|
||||
} else {
|
||||
return 'your OS';
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadUrl = (os) => {
|
||||
switch (os) {
|
||||
case 'Windows':
|
||||
return 'https://git-scm.com/download/win';
|
||||
case 'macOS':
|
||||
return 'https://git-scm.com/download/mac';
|
||||
case 'Linux':
|
||||
return 'https://git-scm.com/download/linux';
|
||||
default:
|
||||
return 'https://git-scm.com/download';
|
||||
}
|
||||
};
|
||||
|
||||
const GitNotFoundModal = ({ onClose }) => {
|
||||
const osName = getOSName();
|
||||
const downloadUrl = getDownloadUrl(osName);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Git Not Found"
|
||||
handleCancel={onClose}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div>
|
||||
<p>Git was not detected on your system. You need to install Git to proceed.</p>
|
||||
<p className="mt-2">
|
||||
You can download Git for <strong>{osName}</strong> here:
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className="text-blue-600 cursor-pointer border-b border-blue-600"
|
||||
onClick={() => window.open(downloadUrl, '_blank')}
|
||||
>
|
||||
Download Git for {osName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitNotFoundModal;
|
||||
@@ -8,7 +8,37 @@ import React, { useState } from 'react';
|
||||
import HelpIcon from 'components/Icons/Help';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Help = ({ children, width = 200 }) => {
|
||||
const getPlacementStyles = (placement) => {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return {
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
};
|
||||
case 'bottom':
|
||||
return {
|
||||
top: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
top: '50%',
|
||||
right: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
case 'right':
|
||||
default:
|
||||
return {
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Help = ({ children, width = 200, placement = 'right' }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -24,9 +54,7 @@ const Help = ({ children, width = 200 }) => {
|
||||
<StyledWrapper
|
||||
className="absolute z-50 rounded-md p-3"
|
||||
style={{
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)',
|
||||
...getPlacementStyles(placement),
|
||||
width: `${width}px`
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
margin: 0.67em 0;
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.4em;
|
||||
font-size: 2.2em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.3em;
|
||||
font-size: 1.7em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 1.2em;
|
||||
font-size: 1.45em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
|
||||
h5 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 1em;
|
||||
font-size: 0.975em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 0.9em;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,10 +154,17 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
// If the secret flag has changed, update the editor to reflect the change
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Font = ({ close }) => {
|
||||
const Font = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
@@ -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,7 +1,32 @@
|
||||
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 {
|
||||
color: ${(props) => props.theme.colors.text.link};
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
form.bruno-form {
|
||||
label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.default-collection-location-input {
|
||||
max-width: 28rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
|
||||
import path from 'utils/common/path';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
|
||||
const General = ({ close }) => {
|
||||
const General = () => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dispatch = useDispatch();
|
||||
const inputFileCaCertificateRef = useRef();
|
||||
@@ -47,8 +47,8 @@ const General = ({ close }) => {
|
||||
.test('isNumber', 'Save Delay must be a number', (value) => {
|
||||
return value === undefined || !isNaN(value);
|
||||
})
|
||||
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
|
||||
return value === undefined || Number(value) >= 100;
|
||||
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
|
||||
return value === undefined || Number(value) >= 500;
|
||||
})
|
||||
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
|
||||
// If autosave is enabled, interval must be provided
|
||||
@@ -174,8 +174,9 @@ const General = ({ close }) => {
|
||||
|
||||
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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user