feat: Enhance EnvironmentVariables component with read-only support for non-string values (#5616)

* feat: Enhance EnvironmentVariables component with read-only support for non-string values

* feat: minor refactor and cleanup worker app state

* fix: playwright test flow

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
This commit is contained in:
Sanjai Kumar
2025-10-01 02:36:45 +05:30
committed by GitHub
parent c7029d1cda
commit 8bad0262c6
14 changed files with 296 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
@@ -41,7 +41,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.string().trim().nullable()
value: Yup.mixed().nullable()
})
),
onSubmit: (values) => {
@@ -160,9 +160,24 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.name}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.name}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
<input

View File

@@ -6,6 +6,19 @@ const StyledWrapper = styled.div`
max-height: 200px;
overflow: auto;
&.read-only {
.CodeMirror .CodeMirror-lines {
cursor: not-allowed !important;
user-select: none !important;
-webkit-user-select: none !important;
-ms-user-select: none !important;
}
.CodeMirror-line {
color: ${(props) => props.theme.colors.text.muted} !important;
}
}
.CodeMirror {
background: transparent;
height: fit-content;

View File

@@ -35,6 +35,7 @@ class MultiLineEditor extends Component {
brunoVarInfo: {
variables
},
readOnly: this.props.readOnly ? 'nocursor' : false,
tabindex: 0,
extraKeys: {
'Ctrl-Enter': () => {
@@ -126,6 +127,9 @@ class MultiLineEditor extends Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly ? 'nocursor' : false);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
@@ -182,9 +186,10 @@ class MultiLineEditor extends Component {
};
render() {
const wrapperClass = `multi-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`;
return (
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper ref={this.editorRef} className="multi-line-editor grow" />
<StyledWrapper ref={this.editorRef} className={wrapperClass} />
{this.secretEye(this.props.isSecret)}
</div>
);

View File

@@ -4,7 +4,8 @@ const { uidSchema } = require('../common');
const environmentVariablesSchema = Yup.object({
uid: uidSchema,
name: Yup.string().nullable(),
value: Yup.string().nullable(),
// Allow mixed types (string, number, boolean, object) to support setting non-string values via scripts.
value: Yup.mixed().nullable(),
type: Yup.string().oneOf(['text']).required('type is required'),
enabled: Yup.boolean().defined(),
secret: Yup.boolean()

View File

@@ -1,32 +1,40 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test('Create collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
// Create a new collection
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
test.describe('Create collection', () => {
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
// Select safe mode
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test('Create collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
// Create a new collection
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
// Create a new request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
// Select safe mode
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
// Send a request
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByTitle('Save Request').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
// Create a new request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
// Verify the response
await expect(page.getByRole('main')).toContainText('200 OK');
// Send a request
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByTitle('Save Request').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify the response
await expect(page.getByRole('main')).toContainText('200 OK');
});
});

View File

@@ -1,6 +1,12 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test.describe('Cross-Collection Drag and Drop for folder', () => {
test.afterEach(async ({ pageWithUserData: page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('Verify cross-collection folder drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
// Create first collection - click dropdown menu first
await page.locator('.dropdown-icon').click();

View File

@@ -1,6 +1,12 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test.describe('Cross-Collection Drag and Drop', () => {
test.afterEach(async ({ pageWithUserData: page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('Verify request drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
// Create first collection - click dropdown menu first
await page.locator('.dropdown-icon').click();

View File

@@ -2,7 +2,7 @@ import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test.describe('Tag persistence', () => {
test.afterAll(async ({ pageWithUserData: page }) => {
test.afterEach(async ({ pageWithUserData: page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
@@ -65,6 +65,7 @@ test.describe('Tag persistence', () => {
// Click on r3 to verify the tag persisted after the move
await page.locator('.collection-item-name').filter({ hasText: 'r3' }).click();
await page.locator('.request-tab.active', { hasText: 'r3' }).waitFor({ state: 'visible' });
await page.getByRole('tab', { name: 'Settings' }).click();
// Verify the tag is still present after the move
@@ -149,6 +150,7 @@ test.describe('Tag persistence', () => {
// Click on r2 to verify the tag persisted after the move
await page.locator('.collection-item-name').filter({ hasText: 'r2' }).click();
await page.locator('.request-tab.active', { hasText: 'r2' }).waitFor({ state: 'visible' });
await page.getByRole('tab', { name: 'Settings' }).click();
await expect(page.getByRole('button', { name: 'smoke' })).toBeVisible();
});

View File

@@ -57,8 +57,8 @@ test.describe('Open Multiple Collections', () => {
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
// Click on Open Collection(s) button
await page.getByRole('button', { name: 'Open Collection' }).click();
// Click on Open Collection button
await page.locator('button').filter({ hasText: 'Open Collection' }).click();
// Wait for both collections to appear in the sidebar
const collection1Element = page.locator('#sidebar-collection-name').getByText('Test Collection 1');
@@ -91,7 +91,7 @@ test.describe('Open Multiple Collections', () => {
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
// Click on Open Collection(s) button
// Click on Open Collection button
await page.getByRole('button', { name: 'Open Collection' }).click();
// Verify no collections were opened

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "global-env-non-string",
"type": "collection"
}

View File

@@ -0,0 +1,16 @@
meta {
name: set-global-nonstring
type: http
seq: 1
}
get {
url: https://example.com
body: none
auth: none
}
script:post-response {
bru.setGlobalEnvVar('numericVar', 170001);
bru.setGlobalEnvVar('booleanVar', true);
}

View File

@@ -0,0 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/global-environments/collection"
]
}

View File

@@ -0,0 +1,134 @@
import { test, expect } from '../../playwright';
import { openCollectionAndAcceptSandbox, closeAllCollections } from '../utils/page';
test.describe('Global Environment Variables - Non-string Values', () => {
test.afterAll(async ({ pageWithUserData: page }) => {
// Cleanup: close all collections
await closeAllCollections(page);
});
test('should seed non-string globals via request and verify read-only + tooltip', async ({
pageWithUserData: page
}) => {
await openCollectionAndAcceptSandbox(page, 'global-env-non-string', 'safe');
await test.step('Create a new global environment with a string variable', async () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
// Create a new global environment
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('#environment-name').click();
await page.locator('#environment-name').fill('Test Env');
await page.getByRole('button', { name: 'Create', exact: true }).click();
// Add a string variable.
await page.getByTestId('add-variable').click();
const newRow = page.locator('tbody tr').last();
await newRow.locator('input[name$=".name"]').fill('stringVar');
await newRow.locator('.CodeMirror').click();
await page.keyboard.type('hello world');
// Save
await page.getByTestId('save-env').click();
// Verify that the string variable value is saved and displayed correctly.
await expect(newRow.locator('.CodeMirror-line').first()).toContainText('hello world');
// Close the environment modal
await page.locator('[data-test-id="modal-close-button"]').click();
});
// Request contains a script that sets the non-string global variables.
await test.step('Run the request to seed non-string global variables via post-script', async () => {
await page.getByText('set-global-nonstring').click();
await page.getByTestId('send-arrow-icon').click();
// wait for the response to arrive
await page.getByTestId('response-status-code').waitFor({ state: 'visible' });
await expect(page.getByTestId('response-status-code')).toHaveText(/200/);
});
await test.step('Re-open Global Environments to see the seeded variables', async () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByRole('button', { name: 'Configure' }).click();
});
const envModal = page
.locator('.bruno-modal-card')
.filter({ has: page.locator('.bruno-modal-header-title', { hasText: 'Global Environments' }) });
const numericInput = envModal.locator('input[value="numericVar"]');
const booleanInput = envModal.locator('input[value="booleanVar"]');
await expect(numericInput).toBeVisible();
await expect(booleanInput).toBeVisible();
const numericRow = numericInput.locator('xpath=ancestor::tr');
const booleanRow = booleanInput.locator('xpath=ancestor::tr');
await test.step('Verify that numericVar is read-only with tooltip', async () => {
// This value is set via a post-script (not user input). We verify that attempts to edit the input do not change the value, proving it is read-only in the UI.
// Verify the script-set value is rendered.
await expect(numericRow.locator('.CodeMirror-line').first()).toContainText(/170001/);
// Verify that typing into the input does not mutate the value.
await numericRow.locator('.CodeMirror').click();
await page.keyboard.type('999');
await expect(numericRow.locator('.CodeMirror-line').first()).toContainText(/170001/);
// Hovering over the info icon reveals the tooltip.
// It is anchored to the info icon element id, so hover/click reveals it reliably.
const infoIcon = page.locator('#numericVar-disabled-info-icon');
await infoIcon.hover();
// The tooltip explains why the field is locked.
const tooltip = page.locator('[role="tooltip"], .react-tooltip');
await expect(tooltip.first()).toBeVisible();
await expect(tooltip.first()).toContainText('Non-string values set via scripts are read-only and can only be updated through scripts.');
// Hovering outside the tooltip should hide it.
await page.mouse.move(0, 0);
await expect(tooltip.first()).not.toBeVisible();
// Clicking the info icon reveals the tooltip.
await infoIcon.click();
await expect(tooltip.first()).toBeVisible();
await expect(tooltip.first()).toContainText('Non-string values set via scripts are read-only and can only be updated through scripts.');
});
await test.step('Verify that booleanVar is read-only with tooltip', async () => {
// This value is also set via post-script.
await expect(booleanRow.locator('.CodeMirror-line').first()).toContainText(/true/);
// Verify that typing into the input does not mutate the value.
await booleanRow.locator('.CodeMirror').click();
await page.keyboard.type('false');
await expect(booleanRow.locator('.CodeMirror-line').first()).toContainText(/true/);
// Hovering over the info icon reveals the tooltip.
const infoIcon = page.locator('#booleanVar-disabled-info-icon');
await infoIcon.hover();
// The tooltip explains why the field is locked.
const tooltip = page.locator('[role="tooltip"], .react-tooltip');
await expect(tooltip.first()).toBeVisible();
await expect(tooltip.first()).toContainText('Non-string values set via scripts are read-only and can only be updated through scripts.');
});
await test.step('Verify that stringVar remains editable', async () => {
// Unlike script-managed values above, this one is user-managed.
const stringInput = envModal.locator('input[value="stringVar"]');
await expect(stringInput).toBeVisible();
const stringRow = stringInput.locator('xpath=ancestor::tr');
await expect(stringRow.locator('.CodeMirror-line').first()).toContainText('hello world');
await stringRow.locator('.CodeMirror').click();
await page.keyboard.type(' updated');
// Verify the user edit persists in the UI.
await expect(stringRow.locator('.CodeMirror-line').first()).toContainText('hello world updated');
// Close the environment modal
await page.locator('[data-test-id="modal-close-button"]').click();
});
});
});

View File

@@ -1,11 +1,48 @@
const closeAllCollections = async (page) => {
const numberOfCollections = await page.locator('.collection-name').count();
import { test, expect } from '../../../playwright';
for (let i = 0; i < numberOfCollections; i++) {
await page.locator('.collection-name').first().locator('.collection-actions').click();
await page.locator('.dropdown-item').getByText('Close').click();
await page.getByRole('button', { name: 'Close' }).click();
}
/**
* Close all collections
* @param page - The page object
* @returns void
*/
const closeAllCollections = async (page) => {
await test.step('Close all collections', async () => {
const numberOfCollections = await page.locator('.collection-name').count();
for (let i = 0; i < numberOfCollections; i++) {
await page.locator('.collection-name').first().locator('.collection-actions').click();
await page.locator('.dropdown-item').getByText('Close').click();
// Wait for the close collection modal to be visible
await page.locator('.bruno-modal-header-title', { hasText: 'Close Collection' }).waitFor({ state: 'visible' });
await page.locator('.bruno-modal-footer .submit').click();
// Wait for the close collection modal to be hidden
await page.locator('.bruno-modal-header-title', { hasText: 'Close Collection' }).waitFor({ state: 'hidden' });
}
// Wait until no collections are left open
await expect(page.locator('.collection-name')).toHaveCount(0);
});
};
export { closeAllCollections };
/**
* Open a collection from the sidebar and accept the JavaScript Sandbox modal
* @param page - The page object
* @param collectionName - The name of the collection to open
* @param sandboxMode - The mode to accept the sandbox modal
* @returns void
*/
const openCollectionAndAcceptSandbox = async (page, collectionName: string, sandboxMode: 'safe' | 'developer' = 'safe') => {
await test.step(`Open collection "${collectionName}" and accept sandbox "${sandboxMode}" mode`, async () => {
await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click();
const sandboxModal = page
.locator('.bruno-modal-card')
.filter({ has: page.locator('.bruno-modal-header-title', { hasText: 'JavaScript Sandbox' }) });
const modeLabel = sandboxMode === 'safe' ? 'Safe Mode' : 'Developer Mode';
await sandboxModal.getByLabel(modeLabel).check();
await sandboxModal.locator('.bruno-modal-footer .submit').click();
});
};
export { closeAllCollections, openCollectionAndAcceptSandbox };