Files
bruno/packages/bruno-app/src/components/MultiLineEditor/index.js
Sid 0bba66a590 feat: variable descriptions (#8269)
* feat: add description column to various tables and enhance UI interactions
- Introduced a description column in Headers, VarsTable, and other components to allow multi-line descriptions.
- Added toggle buttons to show/hide the description column in relevant tables.
- Updated EditableTable component to support dynamic row addition with customizable labels.
- Enhanced UI for better user experience with consistent button styles and spacing adjustments.

* chore: resize fix handles

* fix: restrict column check for last index

* refactor: remove unwanted style and fix bottom padding of the last child in the table row

* tests: improve testability and fix impacted locators

* tests: fix locators for value fields

* fix: improve newline handling in description prefix

* fix: ts changes

* chore: remove redundant code and fix description roundtrip

* chore: remove redundant check

* tests(bru-lang): tests for description and annotation

* test: improve locators and tests

* tests: descriptions

* chore: re-add description setup

* fix: re-add description cols

* chore: fix the double click reset

* fix: account for hidden sidebar in request pane width calculation

* refactor: ui/ux fixes for data type toggle

* chore: inline common deps into bruno-lang and bruno-schema

* chore: tests

* chore: fix ux for descriptions

* fix: layout for environment vars table

* fix: ensure correct row selection for multipart file upload in tests

* feat: enhance UID assignment for request headers and variables in collections

* chore: simplify

* chore: update pkg

* chore: abstract the e2e utils

* chore: fix exports

* tests: fix locators for datatype vars

* fix: ux issue with secrets in environment table

* tests: update locators for collection headers and vars descriptions

* tests: update locators to use data attributes for datatype selectors

* chore: update default css width for actions column

* chore: remove tooltip for string type warnings

* fix: reduce name widths

* refactor: update annotation serialization to use single-quote delimiters for descriptions

* refactor(tests): simplify description formatting in jsonToEnv tests

* feat: add multiline description escaping and unescaping functions with tests

* refactor: clean up imports and improve multiline text block handling

* refactor: update locators to use new test IDs for environment variable editors

* refactor: streamline header input handling and improve environment variable row access

* refactor: update e2e-test job to support self-hosted runners

* refactor: update E2E test runner configuration to include e2e and macOS environments

---------

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
2026-07-07 21:43:39 +05:30

252 lines
8.9 KiB
JavaScript

import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
class MultiLineEditor extends Component {
constructor(props) {
super(props);
// Keep a cached version of the value, this cache will be updated when the
// editor is updated, which can later be used to protect the editor from
// unnecessary updates during the update lifecycle.
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
this.readOnly = props.readOnly || false;
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
'Cmd-F': () => {},
'Ctrl-F': () => {},
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
// Tabbing disabled to make tabindex work
'Tab': false,
'Shift-Tab': false
}
});
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
const getAnywordAutocompleteHints = () => this.props.autocomplete || [];
// Setup AutoComplete Helper
const autoCompleteOptions = {
showHintsFor: ['variables'],
getAllVariables: getAllVariablesHandler,
getAnywordAutocompleteHints
};
this.brunoAutoCompleteCleanup = setupAutoComplete(
this.editor,
autoCompleteOptions
);
setupLinkAware(this.editor);
// Add mousetrap calss so Mousetrap captures shortcuts even when Codemirror is focused
const cmInput = this.editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.editor.on('blur', this._onBlur);
this.addOverlay(variables);
// Initialize masking if this is a secret field
this.setState({ maskInput: this.props.isSecret }, () => {
this.props.onMaskChange?.(this.state.maskInput);
});
this._enableMaskedEditor(this.props.isSecret);
}
_onBlur = () => {
if (this.editor) {
this.editor.setCursor(this.editor.getCursor());
}
};
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
if (this.props.onChange) {
this.props.onChange(this.cachedValue);
}
requestAnimationFrame(() => this.editor?.refresh());
}
};
/** Enable or disable masking the rendered content of the editor */
_enableMaskedEditor = (enabled) => {
if (typeof enabled !== 'boolean') return;
if (enabled == true) {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
} else {
if (this.maskedEditor) {
this.maskedEditor.disable();
this.maskedEditor.destroy();
this.maskedEditor = null;
}
}
};
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
// event loop.
this.ignoreChangeEvent = true;
let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.variables)) {
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
this.editor.options.brunoVarInfo.variables = variables;
}
this.addOverlay(variables);
}
// Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
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);
}
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);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
requestAnimationFrame(() => this.editor?.refresh());
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
this._enableMaskedEditor(this.props.isSecret);
// also set the maskInput flag to the new value
this.setState({ maskInput: this.props.isSecret }, () => {
this.props.onMaskChange?.(this.state.maskInput);
});
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly || false);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();
}
if (this.maskedEditor) {
this.maskedEditor.destroy();
this.maskedEditor = null;
}
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('blur', this._onBlur);
this.editor.getWrapperElement().remove();
}
}
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};
/**
* @brief Toggle the visibility of the secret value
*/
toggleVisibleSecret = () => {
const maskInput = !this.state.maskInput;
this.setState({ maskInput }, () => {
this._enableMaskedEditor(maskInput);
this.props.onMaskChange?.(maskInput);
});
};
/**
* @brief Eye icon to show/hide the secret value
* @returns ReactComponent The eye icon
*/
secretEye = (isSecret) => {
return isSecret === true ? (
<button className="mx-2" data-testid="secret-reveal-toggle" onClick={() => this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
<IconEyeOff size={18} strokeWidth={2} />
) : (
<IconEye size={18} strokeWidth={2} />
)}
</button>
) : null;
};
render() {
const wrapperClass = `multi-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`;
const testId = this.props.testId ?? (this.props.name ? `test-multiline-editor-${this.props.name}` : undefined);
return (
<div data-testid={testId} className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper ref={this.editorRef} className={wrapperClass} />
{!this.props.hideSecretEye && this.secretEye(this.props.isSecret)}
</div>
);
}
}
export default MultiLineEditor;