Environment's as tabs (#6407)

* add: env's as tabs

* fix: test

* fix: tests

* fixes

* fix: test

* fixes

* fixes

* fix

* fix: styling

* fixes
This commit is contained in:
naman-bruno
2025-12-18 16:10:00 +05:30
committed by GitHub
parent 678fa88a7c
commit bc2efb9686
66 changed files with 2198 additions and 1951 deletions

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconBox, IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
import { IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ShareCollection from 'components/ShareCollection/index';
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
const Info = ({ collection }) => {
const dispatch = useDispatch();
@@ -53,7 +53,13 @@ const Info = ({ collection }) => {
type="button"
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
onClick={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
>
{collectionEnvironmentCount} collection environment{collectionEnvironmentCount !== 1 ? 's' : ''}
@@ -61,7 +67,15 @@ const Info = ({ collection }) => {
<button
type="button"
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
onClick={() => dispatch(updateGlobalEnvironmentSettingsModalVisibility(true))}
onClick={() => {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
>
{globalEnvironmentCount} global environment{globalEnvironmentCount !== 1 ? 's' : ''}
</button>

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
return (
<Portal>
<Modal
size="md"
title="Unsaved changes"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<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.
</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
Save
</button>
</div>
</div>
</Modal>
</Portal>
);
};
export default ConfirmCloseEnvironment;

View File

@@ -1,18 +1,16 @@
import React, { useMemo, useState, useRef, forwardRef } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
import { IconWorld, IconDatabase, IconCaretDown } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import toast from 'react-hot-toast';
import EnvironmentListContent from './EnvironmentListContent/index';
import EnvironmentSettings from '../EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -27,8 +25,6 @@ const EnvironmentSelector = ({ collection }) => {
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
const activeGlobalEnvironment = activeGlobalEnvironmentUid
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
: null;
@@ -75,12 +71,24 @@ const EnvironmentSelector = ({ collection }) => {
});
};
// Settings handler
// Settings handler - opens environment settings tab
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
} else {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}
dropdownTippyRef.current.hide();
};
@@ -105,12 +113,6 @@ const EnvironmentSelector = ({ collection }) => {
dropdownTippyRef.current.hide();
};
// Modal handlers
const handleCloseSettings = () => {
dispatch(updateEnvironmentSettingsModalVisibility(false));
dispatch(updateGlobalEnvironmentSettingsModalVisibility(false));
};
// Calculate dropdown width based on the longest environment name.
// To prevent resizing while switching between collection and global environments.
const dropdownWidth = useMemo(() => {
@@ -217,25 +219,17 @@ const EnvironmentSelector = ({ collection }) => {
</Dropdown>
</div>
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{isGlobalEnvironmentSettingsModalOpen && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
activeGlobalEnvironmentUid={activeGlobalEnvironmentUid}
onClose={handleCloseSettings}
/>
)}
{isEnvironmentSettingsModalOpen && (
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
)}
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
/>
)}
@@ -245,7 +239,13 @@ const EnvironmentSelector = ({ collection }) => {
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
/>
)}
@@ -255,7 +255,13 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
/>
)}
@@ -266,7 +272,13 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
/>
)}

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
const ConfirmSwitchEnv = ({ onCancel }) => {
return createPortal(
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
</div>
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
Close
</button>
</div>
<div></div>
</div>
</Modal>,
document.body
);
};
export default ConfirmSwitchEnv;

View File

@@ -1,67 +1,192 @@
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.table-container {
overflow-y: auto;
border-radius: 8px;
border: ${(props) => props.theme.workspace.environments.indentBorder};
}
table {
width: 100%;
border-collapse: collapse;
font-weight: 500;
table-layout: fixed;
font-size: 12px;
thead,
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
vertical-align: middle;
padding: 2px 10px;
&:nth-child(1),
&:nth-child(1) {
width: 25px;
border-right: none;
}
&:nth-child(4) {
width: 70px;
width: 80px;
}
&:nth-child(5) {
width: 40px;
width: 60px;
}
&:nth-child(2) {
width: 25%;
width: 30%;
}
}
thead {
color: ${(props) => props.theme.table.thead.color};
color: ${(props) => props.theme.colors.text};
background: ${(props) => props.theme.sidebar.bg};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
td {
padding: 8px 10px;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
&:last-child {
border-right: none;
}
}
}
thead td {
padding: 6px 10px;
tbody {
tr {
transition: background 0.1s ease;
&:last-child td {
border-bottom: none;
}
td {
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
&:last-child {
border-right: none;
}
}
}
}
}
.btn-add-param {
font-size: ${(props) => props.theme.font.size.base};
font-size: 12px;
color: ${(props) => props.theme.textLink};
font-weight: 500;
padding: 7px 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 6px;
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: transparent;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
.tooltip-mod {
font-size: ${(props) => props.theme.font.size.xs} !important;
width: 150px !important;
font-size: 11px !important;
max-width: 200px !important;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
border: 1px solid transparent;
outline: none !important;
background-color: transparent;
color: ${(props) => props.theme.text};
padding: 0;
font-size: 12px;
border-radius: 4px;
transition: all 0.15s ease;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.workspace.accent};
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: 7px 16px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: none;
background: ${(props) => props.theme.workspace.accent};
color: ${(props) => props.theme.bg};
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.reset {
background: transparent;
padding: 6px 16px;
border: 1px solid ${(props) => props.theme.workspace.accent};
color: ${(props) => props.theme.workspace.accent};
&:hover {
opacity: 0.9;
}
}
.discard {
padding: 7px 16px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
background: transparent;
color: ${(props) => props.theme.text};
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
`;
export default Wrapper;

View File

@@ -1,34 +1,43 @@
import React, { useRef, useEffect, useMemo } from 'react';
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
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 { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { Tooltip } from 'react-tooltip';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
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 });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// Check for non-secret variables used in sensitive fields
const nonSecretSensitiveVarUsageMap = useMemo(() => {
const result = {};
if (!collection || !environment?.variables) {
@@ -44,7 +53,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
const value = get(obj, fieldPath);
if (typeof value === 'string') {
varNames.forEach((varName) => {
if (new RegExp(`\{\{\s*${varName}\s*\}\}`).test(value)) {
if (new RegExp(`\\{\\{\\s*${varName}\\s*\\}\\}`).test(value)) {
result[varName] = true;
}
});
@@ -73,51 +82,147 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
return result;
}, [collection, environment]);
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
// 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: environment.variables || [],
initialValues: initialValues,
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim(),
.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.string().trim().nullable()
value: Yup.mixed().nullable()
})
),
onSubmit: (values) => {
if (!formik.dirty) {
toast.error('Nothing to save');
return;
}
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
dispatch(saveEnvironment(cloneDeep(values), environment.uid, collection.uid))
.then(() => {
toast.success('Changes saved successfully');
formik.resetForm({ values });
setIsModified(false);
})
.catch(() => toast.error('An error occurred while saving the changes'));
}
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: () => {}
});
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
// Restore draft values on mount or environment switch
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
}, [formik.dirty]);
prevEnvUidRef.current = environment.uid;
mountedRef.current = true;
const ErrorMessage = ({ name }) => {
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 }));
}
}, 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 = uuid();
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;
}
@@ -129,54 +234,161 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
);
};
const addVariable = () => {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
formik.setFieldValue(formik.values.length, newVariable, false);
};
const handleRemoveVar = useCallback((id) => {
const currentValues = formik.values;
const onActivate = () => {
dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
onClose();
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
const handleRemoveVar = (id) => {
formik.setValues(formik.values.filter((variable) => variable.uid !== id));
};
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
if (!currentValues || currentValues.length === 0) {
return;
}
}, [formik.values, formik.dirty]);
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 = () => {
formik.resetForm({ originalEnvironmentVariables });
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);
};
}, []);
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
<table className="environment-variables">
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center">Enabled</td>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
@@ -184,99 +396,112 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
<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}
onChange={formik.handleChange}
/>
<ErrorMessage name={`${index}.name`} />
</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}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</div>
{!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">
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
</td>
<td>
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</td>
</tr>
))}
{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>
<button
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
id="add-variable"
data-testid="add-variable"
>
+ Add Variable
</button>
</div>
</div>
<div className="flex items-center">
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit} data-testid="save-env">
<IconDeviceFloppy size={16} strokeWidth={1.5} className="mr-1" />
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset} data-testid="reset-env">
<IconRefresh size={16} strokeWidth={1.5} className="mr-1" />
Reset
</button>
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate} data-testid="activate-env">
<IconCircleCheck size={16} strokeWidth={1.5} className="mr-1" />
Activate
</button>
<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 EnvironmentVariables;

View File

@@ -0,0 +1,134 @@
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: 13px;
font-weight: 600;
color: ${(props) => props.theme.text};
margin: 0;
}
.title-container {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
&.renaming {
.title-input {
flex: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
outline: none;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 600;
padding: 4px 8px;
border-radius: 5px;
}
.inline-actions {
display: flex;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.textLink};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
}
.title-error {
position: absolute;
top: 100%;
left: 20px;
margin-top: 4px;
padding: 4px 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.colors.text.danger};
border-radius: 4px;
white-space: nowrap;
}
.actions {
display: flex;
gap: 2px;
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&:last-child: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;

View File

@@ -1,59 +1,183 @@
import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { useState } from 'react';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { renameEnvironment } 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 ToolHint from 'components/ToolHint/index';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const inputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (name.length < 1) {
return 'Must be at least 1 character';
}
if (name.length > 255) {
return 'Must be 255 characters or less';
}
if (!validateName(name)) {
return validateNameError(name);
}
const trimmedName = name.toLowerCase().trim();
const isDuplicate = (environments || []).some(
(env) => env?.uid !== environment.uid && env?.name?.toLowerCase().trim() === trimmedName
);
if (isDuplicate) {
return 'Environment already exists';
}
return null;
};
const handleRenameClick = () => {
setIsRenaming(true);
setNewName(environment.name);
setNameError('');
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
};
const handleSaveRename = () => {
const error = validateEnvironmentName(newName);
if (error) {
setNameError(error);
return;
}
dispatch(renameEnvironment(newName, environment.uid, collection.uid))
.then(() => {
toast.success('Environment renamed!');
setIsRenaming(false);
setNewName('');
setNameError('');
})
.catch(() => {
toast.error('An error occurred while renaming the environment');
});
};
const handleCancelRename = () => {
setIsRenaming(false);
setNewName('');
setNameError('');
};
const handleNameChange = (e) => {
setNewName(e.target.value);
if (nameError) {
setNameError('');
}
};
const handleNameBlur = () => {
if (newName.trim() === '') {
handleCancelRename();
} else {
const error = validateEnvironmentName(newName);
if (error) {
setNameError(error);
}
}
};
const handleNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelRename();
}
};
return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
{openEditModal && (
<RenameEnvironment onClose={() => setOpenEditModal(false)} environment={environment} collection={collection} />
)}
<StyledWrapper>
{openDeleteModal && (
<DeleteEnvironment
onClose={() => setOpenDeleteModal(false)}
environment={environment}
collection={collection}
/>
<DeleteEnvironment onClose={() => setOpenDeleteModal(false)} environment={environment} collection={collection} />
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} collection={collection} />
)}
<div className="flex">
<div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-medium break-all">{environment.name}</span>
<div className="header">
<div className={`title-container ${isRenaming ? 'renaming' : ''}`}>
{isRenaming ? (
<>
<input
ref={inputRef}
type="text"
className="title-input"
value={newName}
onChange={handleNameChange}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
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>
</>
) : (
<h2 className="title">{environment.name}</h2>
)}
</div>
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash
className="cursor-pointer"
size={20}
strokeWidth={1.5}
onClick={() => setOpenDeleteModal(true)}
data-testid="delete-environment-button"
/>
</ToolHint>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenCopyModal(true)} title="Copy">
<IconCopy size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</div>
</div>
<div>
<EnvironmentVariables environment={environment} collection={collection} setIsModified={setIsModified} onClose={onClose} />
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
</div>
</StyledWrapper>
);
};

View File

@@ -1,61 +1,281 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
display: flex;
height: 100%;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
position: relative;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.environments-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
min-height: 400px;
.environments-container {
display: flex;
height: 100%;
max-height: 85vh;
width: 100%;
overflow: hidden;
}
.confirm-switch-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
}
/* Left Sidebar */
.sidebar {
width: 240px;
min-width: 240px;
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
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};
}
}
}
.search-container {
position: relative;
padding: 0 12px 12px 12px;
.search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-100%);
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 100%;
padding: 6px 8px 6px 28px;
font-size: 12px;
background: transparent;
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
}
}
}
.environments-list {
flex: 1;
overflow-y: auto;
padding: 0 8px;
}
.environment-item {
min-width: 150px;
display: block;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
margin-bottom: 1px;
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 5px;
transition: background 0.15s ease;
.environment-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.environment-actions {
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
.activate-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
cursor: pointer;
color: ${(props) => props.theme.text.muted};
border-radius: 3px;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.colors.text.green};
}
}
.activated-checkmark {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.green};
opacity: 1;
}
}
&:hover .environment-actions {
opacity: 1;
}
&.activated .environment-actions {
opacity: 1;
}
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
background: ${(props) => props.theme.workspace.button.bg};
}
&.active {
background: ${(props) => props.theme.workspace.environments.activeBg};
color: ${(props) => props.theme.text};
}
&.renaming,
&.creating {
cursor: default;
padding: 4px 4px 4px 8px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
}
.rename-container {
display: flex;
align-items: center;
flex: 1;
.environment-name-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
margin-left: 4px;
}
}
&.creating {
.environment-name-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
margin-left: 4px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.textLink};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.btn-create-environment,
.btn-import-environment {
padding: 8px 10px;
cursor: pointer;
border-bottom: none;
color: ${(props) => props.theme.textLink};
span:hover {
text-decoration: underline;
}
}
.btn-import-environment {
color: ${(props) => props.theme.colors.text.muted};
.env-error {
padding: 4px 12px;
margin-top: 4px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => `${props.theme.colors.text.danger}15`};
border-radius: 4px;
}
`;

View File

@@ -1,21 +1,39 @@
import React, { useEffect, useState } from 'react';
import { findEnvironmentInCollection } from 'utils/collections';
import React, { useEffect, useState, useRef } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ManageSecrets from '../ManageSecrets';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ToolHint from 'components/ToolHint';
import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { isEqual } from 'lodash';
import { useDispatch } from 'react-redux';
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
const EnvironmentList = ({
environments,
activeEnvironmentUid,
selectedEnvironment,
setSelectedEnvironment,
isModified,
setIsModified,
collection,
setShowExportModal
}) => {
const dispatch = useDispatch();
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose, setShowExportModal }) => {
const { environments } = collection;
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
const [envNameError, setEnvNameError] = useState('');
const inputRef = useRef(null);
const renameContainerRef = useRef(null);
const createContainerRef = useRef(null);
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
@@ -24,23 +42,36 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (selectedEnvironment) {
const _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(selectedEnvironment.variables);
if (!environments?.length) {
setSelectedEnvironment(null);
setOriginalEnvironmentVariables([]);
return;
}
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if (environment) {
setSelectedEnvironment(environment);
} else {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
if (selectedEnvironment) {
let _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
if (!_selectedEnvironment) {
_selectedEnvironment = environments?.find((env) => env?.name === selectedEnvironment?.name);
}
if (!_selectedEnvironment) {
_selectedEnvironment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
}
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged || selectedEnvironment.uid !== _selectedEnvironment?.uid) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(_selectedEnvironment?.variables || []);
return;
}
}, [collection, environments, selectedEnvironment]);
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
}, [environments, activeEnvironmentUid, selectedEnvironment]);
useEffect(() => {
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
@@ -55,6 +86,36 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
}, [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 (!isModified) {
setSelectedEnvironment(env);
@@ -63,18 +124,141 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
};
const handleEnvironmentDoubleClick = (env) => {
setRenamingEnvUid(env.uid);
setNewEnvName(env.name);
setEnvNameError('');
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
};
const handleActivateEnvironment = (e, env) => {
e.stopPropagation();
dispatch(selectEnvironment(env.uid, collection.uid))
.then(() => {
toast.success(`Environment "${env.name}" activated`);
})
.catch(() => {
toast.error('Failed to activate environment');
});
};
if (!selectedEnvironment) {
return null;
}
const validateEnvironmentName = (name, excludeUid = null) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (!validateName(name)) {
return validateNameError(name);
}
const trimmedName = name.toLowerCase().trim();
const isDuplicate = environments.some(
(env) => env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName
);
if (isDuplicate) {
return 'Environment already exists';
}
return null;
};
const handleCreateEnvClick = () => {
if (!isModified) {
setOpenCreateModal(true);
setIsCreatingInline(true);
setNewEnvName('');
setEnvNameError('');
setTimeout(() => {
inputRef.current?.focus();
}, 50);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleCancelCreate = () => {
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
};
const handleSaveNewEnv = () => {
const error = validateEnvironmentName(newEnvName);
if (error) {
setEnvNameError(error);
return;
}
dispatch(addEnvironment(newEnvName, collection.uid))
.then(() => {
toast.success('Environment created!');
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
})
.catch(() => {
toast.error('An error occurred while creating the environment');
});
};
const handleEnvNameChange = (e) => {
const value = e.target.value;
setNewEnvName(value);
if (envNameError) {
setEnvNameError('');
}
};
const handleEnvNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (renamingEnvUid) {
handleSaveRename();
} else {
handleSaveNewEnv();
}
} else if (e.key === 'Escape') {
e.preventDefault();
if (renamingEnvUid) {
handleCancelRename();
} else {
handleCancelCreate();
}
}
};
const handleSaveRename = () => {
const error = validateEnvironmentName(newEnvName, renamingEnvUid);
if (error) {
setEnvNameError(error);
return;
}
dispatch(renameEnvironment(newEnvName, renamingEnvUid, collection.uid))
.then(() => {
toast.success('Environment renamed!');
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
})
.catch(() => {
toast.error('An error occurred while renaming the environment');
});
};
const handleCancelRename = () => {
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
};
const handleImportClick = () => {
if (!isModified) {
setOpenImportModal(true);
@@ -83,8 +267,10 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
};
const handleSecretsClick = () => {
setOpenManageSecretsModal(true);
const handleExportClick = () => {
if (setShowExportModal) {
setShowExportModal(true);
}
};
const handleConfirmSwitch = (saveChanges) => {
@@ -93,59 +279,160 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
};
const filteredEnvironments
= environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
{openImportModal && (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />
)}
<div className="flex">
<div>
{switchEnvConfirmClose && (
<div className="flex items-center justify-between tab-container px-1">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="environments-sidebar flex flex-col">
{environments
&& environments.length
&& environments.map((env) => (
<ToolHint key={env.uid} text={env.name} toolhintId={env.uid} place="right">
<div
id={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
>
<span className="break-all">{env.name}</span>
</div>
</ToolHint>
))}
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>
</div>
<div className="environments-container">
{switchEnvConfirmClose && (
<div className="confirm-switch-overlay">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="mt-auto btn-import-environment">
<div className="flex items-center" onClick={() => handleImportClick()}>
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => setShowExportModal(true)}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
</div>
<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>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
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}>
<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>
) : (
<>
<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"
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"
>
<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>
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
collection={collection}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
onClose={onClose}
collection={collection}
/>
</div>
</StyledWrapper>

View File

@@ -1,31 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
const ManageSecrets = ({ onClose }) => {
return (
<Portal>
<Modal size="sm" title="Manage Secrets" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<div>
<p>In any collection, there are secrets that need to be managed.</p>
<p className="mt-2">These secrets can be anything such as API keys, passwords, or tokens.</p>
<p className="mt-4">Bruno offers three approaches to manage secrets in collections.</p>
<p className="mt-2">
Read more about it in our{' '}
<a
href="https://docs.usebruno.com/secrets-management/overview"
target="_blank"
rel="noreferrer"
className="text-link hover:underline"
>
docs
</a>
.
</p>
</div>
</Modal>
</Portal>
);
};
export default ManageSecrets;

View File

@@ -1,89 +0,0 @@
import React, { useEffect, useRef } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment, collection }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('name is required')
}),
onSubmit: (values) => {
if (values.name === environment.name) {
return;
}
dispatch(renameEnvironment(values.name, environment.uid, collection.uid))
.then(() => {
toast.success('Environment renamed successfully');
onClose();
})
.catch(() => toast.error('An error occurred while renaming the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Rename Environment"
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default RenameEnvironment;

View File

@@ -1,11 +1,51 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
button.btn-create-environment {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
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;
}
}
.shared-button {
padding: 5px 10px;
font-size: 12px;
border-radius: 5px;
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&:hover {
span {
text-decoration: underline;
}
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
`;

View File

@@ -1,87 +1,64 @@
import Modal from 'components/Modal/index';
import React, { useState } from 'react';
import CreateEnvironment from './CreateEnvironment';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-medium text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const DefaultTab = ({ setTab }) => {
return (
<div className="text-center items-center flex flex-col">
<IconFileAlert size={64} strokeWidth={1} />
<span className="font-medium mt-2">No environments found</span>
<span className="font-extralight mt-2 text-zinc-500 dark:text-zinc-400">
Get started by using the following buttons :
</span>
<div className="flex items-center justify-center mt-6">
<SharedButton onClick={() => setTab('create')}>
<span>Create Environment</span>
</SharedButton>
<span className="mx-4">Or</span>
<SharedButton onClick={() => setTab('import')}>
<span>Import Environment</span>
</SharedButton>
</div>
const DefaultTab = ({ setTab }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<button className="shared-button" onClick={() => setTab('create')}>
Create Environment
</button>
<button className="shared-button" onClick={() => setTab('import')}>
Import Environment
</button>
</div>
);
};
</div>
);
const EnvironmentSettings = ({ collection, onClose }) => {
const EnvironmentSettings = ({ collection }) => {
const [isModified, setIsModified] = useState(false);
const { environments } = collection;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
const environments = collection?.environments || [];
if (!environments || !environments.length) {
return (
<StyledWrapper>
<Modal size="md" title="Environments" handleCancel={onClose} hideCancel={true} hideFooter={true}>
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</Modal>
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
onClose={onClose}
setShowExportModal={setShowExportModal}
/>
</Modal>
<EnvironmentList
environments={environments}
activeEnvironmentUid={collection?.activeEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
setShowExportModal={setShowExportModal}
/>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={collection.environments}
environments={environments}
environmentType="collection"
/>
)}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments';
const GlobalEnvironmentSettings = () => {
return <WorkspaceEnvironments />;
};
export default GlobalEnvironmentSettings;

View File

@@ -1,78 +0,0 @@
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
import { useFormik } from 'formik';
import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import * as Yup from 'yup';
const CopyEnvironment = ({ environment, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name + ' - Copy'
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
.then(() => {
toast.success('Global environment created!');
onClose();
})
.catch((error) => {
toast.error('An error occurred while created the environment');
console.error(error);
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal size="sm" title="Copy Global Environment" confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
New Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CopyEnvironment;

View File

@@ -1,100 +0,0 @@
import React, { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch, useSelector } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const validateEnvironmentName = (name) => {
const trimmedName = name?.toLowerCase().trim();
return globalEnvs.every((env) => env?.name?.toLowerCase().trim() !== trimmedName);
};
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: ''
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'Must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('Name is required')
.test('duplicate-name', 'Global Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addGlobalEnvironment({ name: values.name }))
.then(() => {
toast.success('Global environment created!');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch(() => toast.error('An error occurred while creating the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Create Global Environment"
confirmText="Create"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
Environment Name
</label>
<div className="flex items-center mt-2">
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
</div>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CreateEnvironment;

View File

@@ -1,15 +0,0 @@
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;

View File

@@ -1,37 +0,0 @@
import React from 'react';
import Portal from 'components/Portal/index';
import toast from 'react-hot-toast';
import Modal from 'components/Modal/index';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const DeleteEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
const onConfirm = () => {
dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid }))
.then(() => {
toast.success('Global Environment deleted successfully');
onClose();
})
.catch(() => toast.error('An error occurred while deleting the environment'));
};
return (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title="Delete Global Environment"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
>
Are you sure you want to delete <span className="font-medium">{environment.name}</span> ?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteEnvironment;

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
const ConfirmSwitchEnv = ({ onCancel }) => {
return createPortal(
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
</div>
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
Close
</button>
</div>
<div></div>
</div>
</Modal>,
document.body
);
};
export default ConfirmSwitchEnv;

View File

@@ -1,67 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 500;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
vertical-align: middle;
&:nth-child(1),
&:nth-child(4) {
width: 70px;
}
&:nth-child(5) {
width: 40px;
}
&:nth-child(2) {
width: 25%;
}
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
thead td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: ${(props) => props.theme.font.size.base};
}
.tooltip-mod {
font-size: ${(props) => props.theme.font.size.xs} !important;
width: 150px !important;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
background-color: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
vertical-align: middle;
margin: 0;
}
`;
export default Wrapper;

View File

@@ -1,224 +0,0 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
initialValues: environment.variables || [],
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.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()
})
),
onSubmit: (values) => {
if (!formik.dirty) {
toast.error('Nothing to save');
return;
}
dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(values) }))
.then(() => {
toast.success('Changes saved successfully');
formik.resetForm({ values });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}
});
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
}, [formik.dirty]);
const ErrorMessage = ({ name }) => {
const meta = formik.getFieldMeta(name);
const id = uuid();
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 addVariable = () => {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
formik.setFieldValue(formik.values.length, newVariable, false);
};
const handleRemoveVar = (id) => {
formik.setValues(formik.values.filter((variable) => variable.uid !== id));
};
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [formik.values, formik.dirty]);
const handleReset = () => {
formik.resetForm({ originalEnvironmentVariables });
};
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
<table>
<thead>
<tr>
<td className="text-center">Enabled</td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
</td>
<td>
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
onChange={formik.handleChange}
/>
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</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
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
</td>
<td>
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<div>
<button
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
data-testid="add-variable"
>
+ Add Variable
</button>
</div>
</div>
<div>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit} data-testid="save-env">
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariables;

View File

@@ -1,58 +0,0 @@
import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { useState } from 'react';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ToolHint from 'components/ToolHint/index';
const EnvironmentDetails = ({ environment, setIsModified, collection, allEnvironments }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
{openEditModal && (
<RenameEnvironment onClose={() => setOpenEditModal(false)} environment={environment} />
)}
{openDeleteModal && (
<DeleteEnvironment
onClose={() => setOpenDeleteModal(false)}
environment={environment}
/>
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} />
)}
<div className="flex">
<div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-medium break-all">{environment.name}</span>
</div>
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} data-testid="delete-environment-button" />
</ToolHint>
</div>
</div>
<div>
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
allEnvironments={allEnvironments}
/>
</div>
</div>
);
};
export default EnvironmentDetails;

View File

@@ -1,62 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.environments-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
min-height: 400px;
height: 100%;
max-height: 85vh;
overflow-y: auto;
}
.environment-item {
min-width: 150px;
display: block;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.btn-create-environment,
.btn-import-environment {
padding: 8px 10px;
cursor: pointer;
border-bottom: none;
color: ${(props) => props.theme.textLink};
span:hover {
text-decoration: underline;
}
}
.btn-import-environment {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -1,163 +0,0 @@
import React, { useEffect, useState } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { isEqual } from 'lodash';
import ToolHint from 'components/ToolHint/index';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (!environments?.length) {
setSelectedEnvironment(null);
setOriginalEnvironmentVariables([]);
return;
}
if (selectedEnvironment) {
const _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(selectedEnvironment.variables);
return;
}
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0] || null;
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
}, [environments, activeEnvironmentUid]);
useEffect(() => {
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
if (newEnv) {
setSelectedEnvironment(newEnv);
}
}
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [envUids, environments, prevEnvUids]);
const handleEnvironmentClick = (env) => {
if (!isModified) {
setSelectedEnvironment(env);
} else {
setSwitchEnvConfirmClose(true);
}
};
if (!selectedEnvironment) {
return null;
}
const handleCreateEnvClick = () => {
if (!isModified) {
setOpenCreateModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleImportClick = () => {
if (!isModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleSecretsClick = () => {
setOpenManageSecretsModal(true);
};
const handleExportClick = () => {
if (setShowExportModal) {
setShowExportModal(true);
}
};
const handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
}
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
<div>
{switchEnvConfirmClose && (
<div className="flex items-center justify-between tab-container px-1">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="environments-sidebar flex flex-col">
{environments
&& environments.length
&& environments.map((env) => (
<ToolHint key={env.uid} text={env.name} toolhintId={env.uid} place="right">
<div
id={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle click
>
<span className="break-all">{env.name}</span>
</div>
</ToolHint>
))}
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>
</div>
<div className="mt-auto btn-import-environment">
<div className="flex items-center" onClick={() => handleImportClick()}>
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleExportClick()}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
</div>
</div>
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
allEnvironments={environments}
/>
</div>
</StyledWrapper>
);
};
export default EnvironmentList;

View File

@@ -1,92 +0,0 @@
import React, { useEffect, useRef } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('name is required')
}),
onSubmit: (values) => {
if (values.name === environment.name) {
return;
}
dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
.then(() => {
toast.success('Environment renamed successfully');
onClose();
})
.catch((error) => {
toast.error('An error occurred while renaming the environment');
console.error(error);
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Rename Environment"
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default RenameEnvironment;

View File

@@ -1,13 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
button.btn-create-environment {
&:hover {
span {
text-decoration: underline;
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,90 +0,0 @@
import Modal from 'components/Modal/index';
import React, { useState } from 'react';
import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-medium text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const DefaultTab = ({ setTab }) => {
return (
<div className="text-center items-center flex flex-col">
<IconFileAlert size={64} strokeWidth={1} />
<span className="font-medium mt-2">No Global Environments found</span>
<div className="flex items-center justify-center mt-6">
<SharedButton onClick={() => setTab('create')}>
<span>Create Global Environment</span>
</SharedButton>
<span className="mx-4">Or</span>
<SharedButton onClick={() => setTab('import')}>
<span>Import Environment</span>
</SharedButton>
</div>
</div>
);
};
const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvironmentUid, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
<Modal size="md" title="Global Environments" handleCancel={onClose} hideCancel={true} hideFooter={true}>
{tab === 'create' ? (
<CreateEnvironment onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</Modal>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
setShowExportModal={setShowExportModal}
/>
</Modal>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={globalEnvironments}
environmentType="global"
/>
)}
</StyledWrapper>
);
};
export default EnvironmentSettings;

View File

@@ -34,6 +34,8 @@ import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import ResponseExample from 'components/ResponseExample';
import WorkspaceHome from 'components/WorkspaceHome';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 480;
@@ -147,8 +149,6 @@ const RequestTabPanel = () => {
};
}, [handleMouseUp, handleMouseMove]);
// When devtools opens in vertical layout, reduce request pane height to ensure response pane is visible
// When devtools closes, restore the previous height
useEffect(() => {
if (!isVerticalLayout) return;
@@ -171,11 +171,15 @@ const RequestTabPanel = () => {
}
}, [isConsoleOpen, isVerticalLayout]);
if (!activeTabUid) {
if (!activeTabUid || !focusedTab) {
return <WorkspaceHome />;
}
if (!focusedTab || !focusedTab.uid || !focusedTab.collectionUid) {
if (focusedTab.type === 'global-environment-settings') {
return <GlobalEnvironmentSettings />;
}
if (!focusedTab.uid || !focusedTab.collectionUid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
@@ -226,6 +230,10 @@ const RequestTabPanel = () => {
return <SecuritySettings collection={collection} />;
}
if (focusedTab.type === 'environment-settings') {
return <EnvironmentSettings collection={collection} />;
}
if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />;
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
const getTabInfo = (type, tabName) => {
@@ -53,6 +53,22 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
</>
);
}
case 'environment-settings': {
return (
<>
<IconDatabase size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Environments</span>
</>
);
}
case 'global-environment-settings': {
return (
<>
<IconWorld size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Global Environments</span>
</>
);
}
}
};

View File

@@ -1,16 +1,19 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import darkTheme from 'themes/dark';
import lightTheme from 'themes/light';
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
import ConfirmRequestClose from './ConfirmRequestClose';
import ConfirmCollectionClose from './ConfirmCollectionClose';
import ConfirmFolderClose from './ConfirmFolderClose';
import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnvironment';
import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
@@ -21,6 +24,7 @@ import GradientCloseButton from './GradientCloseButton';
import { flattenItems } from 'utils/collections/index';
import { closeWsConnection } from 'utils/network/index';
import ExampleTab from '../ExampleTab';
import toast from 'react-hot-toast';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => {
const dispatch = useDispatch();
@@ -31,6 +35,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const [showConfirmClose, setShowConfirmClose] = useState(false);
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false);
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
@@ -152,8 +158,31 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
const hasEnvironmentDraft = tab.type === 'environment-settings' && collection?.environmentsDraft;
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft;
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
const handleCloseEnvironmentSettings = (event) => {
if (!collection?.environmentsDraft) {
return handleCloseClick(event);
}
event.stopPropagation();
event.preventDefault();
setShowConfirmEnvironmentClose(true);
};
const handleCloseGlobalEnvironmentSettings = (event) => {
if (!globalEnvironmentDraft) {
return handleCloseClick(event);
}
event.stopPropagation();
event.preventDefault();
setShowConfirmGlobalEnvironmentClose(true);
};
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings', 'environment-settings', 'global-environment-settings'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
@@ -214,12 +243,70 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
/>
)}
{showConfirmEnvironmentClose && tab.type === 'environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={false}
onCancel={() => setShowConfirmEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
}}
onSaveAndClose={() => {
const draft = collection.environmentsDraft;
if (draft?.environmentUid && draft?.variables) {
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
.then(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
toast.success('Environment saved');
})
.catch((err) => {
console.log('err', err);
toast.error('Failed to save environment');
});
}
}}
/>
)}
{showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={true}
onCancel={() => setShowConfirmGlobalEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
}}
onSaveAndClose={() => {
const draft = globalEnvironmentDraft;
if (draft?.environmentUid && draft?.variables) {
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
.then(() => {
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
toast.success('Global environment saved');
})
.catch((err) => {
console.log('err', err);
toast.error('Failed to save global environment');
});
}
}}
/>
)}
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseFolderSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} />
) : tab.type === 'collection-settings' ? (
<SpecialTab handleCloseClick={handleCloseCollectionSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={collection?.name} hasDraft={hasDraft} />
) : tab.type === 'environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} />
) : tab.type === 'global-environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseGlobalEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}

View File

@@ -83,10 +83,6 @@ const RequestTabs = () => {
return null;
}
if (!activeTab) {
return <StyledWrapper>Something went wrong!</StyledWrapper>;
}
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;

View File

@@ -32,12 +32,12 @@ const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('Name is required')
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
.test('duplicate-name', 'Global environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addGlobalEnvironment({ name: values.name }))
.then(() => {
toast.success('Environment created!');
toast.success('Global environment created!');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
@@ -62,7 +62,7 @@ const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
<Portal>
<Modal
size="sm"
title="Create Environment"
title="Create Global Environment"
confirmText="Create"
handleConfirm={onSubmit}
handleCancel={onClose}

View File

@@ -139,7 +139,7 @@ const Wrapper = styled.div`
}
.button-container {
padding: 12px 0;
padding: 12px 2px;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
display: flex;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback, useRef } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
@@ -10,14 +10,26 @@ import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import {
saveGlobalEnvironment,
setGlobalEnvironmentDraft,
clearGlobalEnvironmentDraft
} from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const { globalEnvironments, activeGlobalEnvironmentUid, globalEnvironmentDraft } = useSelector(
(state) => state.globalEnvironments
);
const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid;
// Track environment changes for draft restoration
const prevEnvUidRef = React.useRef(null);
const mountedRef = React.useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
@@ -26,6 +38,8 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// Initial values based only on saved environment variables (not draft)
// Draft restoration happens in a separate effect to avoid infinite loops
const initialValues = React.useMemo(() => {
const vars = environment.variables || [];
return [
@@ -67,12 +81,10 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
// Skip validation for the last empty row
if (isLastRow && isEmptyRow) {
return;
}
// Validate name for non-empty rows
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
@@ -86,20 +98,61 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
onSubmit: () => {}
});
// Restore draft values on mount or environment switch
React.useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
prevEnvUidRef.current = environment.uid;
mountedRef.current = true;
if ((isMount || envChanged) && hasDraftForThisEnv && globalEnvironmentDraft?.variables) {
formik.setValues([
...globalEnvironmentDraft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, hasDraftForThisEnv, globalEnvironmentDraft?.variables]);
// Sync draft state to Redux
React.useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const hasActualChanges = JSON.stringify(currentValues) !== JSON.stringify(savedValues);
const currentValuesJson = JSON.stringify(currentValues);
const savedValuesJson = JSON.stringify(savedValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, environment.variables, setIsModified]);
// Get existing draft for comparison
const existingDraftVariables = hasDraftForThisEnv ? globalEnvironmentDraft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
if (hasActualChanges) {
// Only dispatch if draft values are actually different
if (currentValuesJson !== existingDraftJson) {
dispatch(setGlobalEnvironmentDraft({
environmentUid: environment.uid,
variables: currentValues
}));
}
} else if (hasDraftForThisEnv) {
dispatch(clearGlobalEnvironmentDraft());
}
}, [formik.values, environment.variables, environment.uid, setIsModified, dispatch, hasDraftForThisEnv, globalEnvironmentDraft?.variables]);
const ErrorMessage = ({ name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
// Don't show error for the last empty row
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
@@ -119,39 +172,47 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
);
};
const handleRemoveVar = (id) => {
const filteredValues = formik.values.filter((variable) => variable.uid !== id);
const handleRemoveVar = useCallback((id) => {
const currentValues = formik.values;
const lastRow = formik.values[formik.values.length - 1];
const isLastEmptyRow = lastRow.uid === id && (!lastRow.name || lastRow.name.trim() === '');
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() === '');
if (!hasEmptyLastRow) {
filteredValues.push({
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
});
}
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.setValues(filteredValues);
};
formik.setValues(newValues);
}, [formik.values]);
const handleNameChange = (index, e) => {
formik.handleChange(e);
const isLastRow = index === formik.values.length - 1;
// If typing in the last row, add a new empty row
if (isLastRow) {
const newVariable = {
uid: uuid(),
@@ -161,15 +222,32 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
secret: false,
enabled: true
};
// Use setTimeout to ensure the change is processed first
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() === '') {
@@ -223,8 +301,24 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
};
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
React.useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
};
}, []);
return (
<StyledWrapper>
<div className="table-container">
@@ -271,6 +365,8 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
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>
@@ -286,6 +382,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
@@ -341,4 +438,5 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</StyledWrapper>
);
};
export default EnvironmentVariables;

View File

@@ -81,7 +81,7 @@ const StyledWrapper = styled.div`
.title-error {
position: absolute;
top: 100%;
left: 0;
left: 20px;
margin-top: 4px;
padding: 4px 8px;
font-size: 11px;

View File

@@ -3,6 +3,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
height: 100%;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
position: relative;
@@ -10,6 +11,7 @@ const StyledWrapper = styled.div`
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
.confirm-switch-overlay {

View File

@@ -4,6 +4,7 @@ const StyledWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
.empty-state {

View File

@@ -145,7 +145,7 @@ const WorkspaceHome = () => {
const tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'environments', label: 'Environments' }
{ id: 'environments', label: 'Global Environments' }
];
const renderTabContent = () => {

View File

@@ -3,7 +3,6 @@ import toast from 'react-hot-toast';
import find from 'lodash/find';
import Mousetrap from 'mousetrap';
import { useSelector, useDispatch } from 'react-redux';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import GlobalSearchModal from 'components/GlobalSearchModal';
@@ -15,7 +14,7 @@ import {
saveCollectionSettings
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { addTab, closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
@@ -26,9 +25,6 @@ export const HotkeysProvider = (props) => {
const tabs = useSelector((state) => state.tabs.tabs);
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
@@ -44,23 +40,24 @@ export const HotkeysProvider = (props) => {
// save hotkey
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
if (isEnvironmentSettingsModalOpen || isGlobalEnvironmentSettingsModalOpen) {
console.log('todo: save environment settings');
} else {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item && item.uid) {
if (activeTab.type === 'folder-settings') {
dispatch(saveFolderRoot(collection.uid, item.uid));
} else {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionSettings(collection.uid));
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
window.dispatchEvent(new CustomEvent('environment-save'));
return false;
}
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item && item.uid) {
if (activeTab.type === 'folder-settings') {
dispatch(saveFolderRoot(collection.uid, item.uid));
} else {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionSettings(collection.uid));
}
}
}
@@ -71,7 +68,7 @@ export const HotkeysProvider = (props) => {
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
};
}, [activeTabUid, tabs, saveRequest, collections, isEnvironmentSettingsModalOpen, isGlobalEnvironmentSettingsModalOpen]);
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
// send request (ctrl/cmd + enter)
useEffect(() => {
@@ -120,7 +117,13 @@ export const HotkeysProvider = (props) => {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
setShowEnvSettingsModal(true);
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}
}
@@ -130,7 +133,7 @@ export const HotkeysProvider = (props) => {
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]);
};
}, [activeTabUid, tabs, collections, setShowEnvSettingsModal]);
}, [activeTabUid, tabs, collections, dispatch]);
// new request (ctrl/cmd + b)
useEffect(() => {
@@ -281,9 +284,6 @@ export const HotkeysProvider = (props) => {
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showEnvSettingsModal && (
<EnvironmentSettings collection={currentCollection} onClose={() => setShowEnvSettingsModal(false)} />
)}
{showNewRequestModal && (
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
)}

View File

@@ -69,12 +69,6 @@ export const appSlice = createSlice({
updateIsDragging: (state, action) => {
state.isDragging = action.payload.isDragging;
},
updateEnvironmentSettingsModalVisibility: (state, action) => {
state.isEnvironmentSettingsModalOpen = action.payload;
},
updateGlobalEnvironmentSettingsModalVisibility: (state, action) => {
state.isGlobalEnvironmentSettingsModalOpen = action.payload;
},
showHomePage: (state) => {
state.showHomePage = true;
state.showApiSpecPage = false;
@@ -141,8 +135,6 @@ export const {
refreshScreenWidth,
updateLeftSidebarWidth,
updateIsDragging,
updateEnvironmentSettingsModalVisibility,
updateGlobalEnvironmentSettingsModalVisibility,
showHomePage,
hideHomePage,
showManageWorkspacePage,

View File

@@ -694,6 +694,19 @@ export const collectionsSlice = createSlice({
folder.draft = null;
}
},
setEnvironmentsDraft: (state, action) => {
const { collectionUid, environmentUid, variables } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.environmentsDraft = { environmentUid, variables };
}
},
clearEnvironmentsDraft: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
collection.environmentsDraft = null;
}
},
newEphemeralHttpRequest: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -3388,6 +3401,8 @@ export const {
saveFolderDraft,
deleteCollectionDraft,
deleteFolderDraft,
setEnvironmentsDraft,
clearEnvironmentsDraft,
newEphemeralHttpRequest,
collapseFullCollection,
toggleCollection,

View File

@@ -5,7 +5,8 @@ import { cloneDeep, has } from 'lodash';
const initialState = {
globalEnvironments: [],
activeGlobalEnvironmentUid: null
activeGlobalEnvironmentUid: null,
globalEnvironmentDraft: null
};
export const globalEnvironmentsSlice = createSlice({
@@ -73,6 +74,13 @@ export const globalEnvironmentsSlice = createSlice({
state.activeGlobalEnvironmentUid = null;
}
}
},
setGlobalEnvironmentDraft: (state, action) => {
const { environmentUid, variables } = action.payload;
state.globalEnvironmentDraft = { environmentUid, variables };
},
clearGlobalEnvironmentDraft: (state) => {
state.globalEnvironmentDraft = null;
}
}
});
@@ -84,7 +92,9 @@ export const {
_renameGlobalEnvironment,
_copyGlobalEnvironment,
_selectGlobalEnvironment,
_deleteGlobalEnvironment
_deleteGlobalEnvironment,
setGlobalEnvironmentDraft,
clearGlobalEnvironmentDraft
} = globalEnvironmentsSlice.actions;
const getWorkspaceContext = (state) => {

View File

@@ -24,7 +24,9 @@ export const tabsSlice = createSlice({
const nonReplaceableTabTypes = [
'variables',
'collection-runner',
'security-settings'
'security-settings',
'environment-settings',
'global-environment-settings'
];
const existingTab = find(state.tabs, (tab) => tab.uid === uid);

View File

@@ -39,17 +39,17 @@ export const validateNameError = (name) => {
}
if (!firstCharacter.test(name[0])) {
return 'Invalid first character.';
return `Special characters aren't allowed in the name. Invalid character '${name[0]}'.`;
}
for (let i = 1; i < name.length - 1; i++) {
if (!middleCharacters.test(name[i])) {
return `Invalid character '${name[i]}' at position ${i + 1}.`;
return `Special characters aren't allowed in the name. Invalid character '${name[i]}'.`;
}
}
if (!lastCharacter.test(name[name.length - 1])) {
return 'Invalid last character.';
return `Special characters aren't allowed in the name. Invalid character '${name[name.length - 1]}'.`;
}
return '';

View File

@@ -28,9 +28,14 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
await page.getByTestId('environment-selector-trigger').click();
// open environment configuration
await page.locator('#configure-env').click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
await page.getByTestId('modal-close-button').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
// we restart the app to confirm that the environment variable is persisted
const newApp = await restartApp();
@@ -43,11 +48,15 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
// open environment dropdown
await newPage.getByTestId('environment-selector-trigger').click();
await newPage.locator('#configure-env').click();
const newEnvTab = newPage.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(newEnvTab).toBeVisible();
await expect(newPage.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
await expect(newPage.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
// close the environment modal
await newPage.getByTestId('modal-close-button').click();
await newEnvTab.hover();
await newEnvTab.getByTestId('request-tab-close-icon').click();
// Restore the original Stage.bru file
fs.writeFileSync(originalStageBruPath, originalStageBruContent);

View File

@@ -21,9 +21,14 @@ test.describe.serial('bru.setEnvVar(name, value)', () => {
// confirm that the environment variable is set
await page.getByTestId('environment-selector-trigger').click();
await page.locator('#configure-env').click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
await page.getByTestId('modal-close-button').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
// we restart the app to confirm that the environment variable is not persisted
const newApp = await restartApp();
@@ -37,11 +42,13 @@ test.describe.serial('bru.setEnvVar(name, value)', () => {
await newPage.getByTestId('environment-selector-trigger').click();
await newPage.locator('#configure-env').click();
// ensure that the environment variable is not persisted
await expect(newPage.locator('table.environment-variables tbody')).not.toContainText('token');
const newEnvTab = newPage.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(newEnvTab).toBeVisible();
// close the environment variable modal
await newPage.getByTestId('modal-close-button').click();
await expect(newPage.locator('.table-container tbody')).not.toContainText('token');
await newEnvTab.hover();
await newEnvTab.getByTestId('request-tab-close-icon').click();
await newPage.close();
});
});

View File

@@ -14,18 +14,20 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => {
await page.locator('#configure-env').click();
await page.waitForTimeout(200);
// Remove the test environment variables
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
const key1Row = page.getByRole('row', { name: 'multiple-persist-vars-key1' });
if (await key1Row.isVisible()) {
await key1Row.getByRole('button').click(); // Click the delete button
await key1Row.getByRole('button').click();
}
const key2Row = page.getByRole('row', { name: 'multiple-persist-vars-key2' });
if (await key2Row.isVisible()) {
await key2Row.getByRole('button').click(); // Click the delete button
await key2Row.getByRole('button').click();
}
await page.getByTestId('modal-close-button').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
}
} catch (error) {
// Ignore cleanup errors to avoid masking test failures
@@ -74,11 +76,16 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => {
await page.waitForTimeout(200);
await page.locator('#configure-env').click();
await page.waitForTimeout(200);
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
await expect(page.getByRole('row', { name: 'multiple-persist-vars-key1' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'value1' }).getByRole('cell').nth(2)).toBeVisible();
await expect(page.getByRole('row', { name: 'multiple-persist-vars-key2' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'value2' }).getByRole('cell').nth(2)).toBeVisible();
await page.getByTestId('modal-close-button').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Verify variables are persisted to file', async () => {

View File

@@ -28,15 +28,13 @@ test.describe('Collection Environment Configuration Selection Tests', () => {
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
// Verify the config modal opens with the currently active environment selected
const collectionEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(collectionEnvModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
// Check that the active environment in the config matches prod
const activeEnvItem = collectionEnvModal.locator('.environment-item.active');
const activeEnvItem = page.locator('.environment-item.active');
await expect(activeEnvItem).toContainText('prod');
// Close the collection environment config modal
await page.getByText('×').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});

View File

@@ -5,7 +5,6 @@ import {
createEnvironment,
addEnvironmentVariables,
saveEnvironment,
closeEnvironmentPanel,
sendRequest,
expectResponseContains,
removeCollection
@@ -39,7 +38,6 @@ test.describe('Collection Environment Create Tests', () => {
]);
await saveEnvironment(page);
await closeEnvironmentPanel(page);
await expect(locators.environment.currentEnvironment()).toContainText('Test Environment');
});

View File

@@ -5,7 +5,6 @@ import {
createEnvironment,
addEnvironmentVariables,
saveEnvironment,
closeEnvironmentPanel,
sendRequest,
expectResponseContains,
closeAllCollections
@@ -41,7 +40,6 @@ test.describe('Global Environment Create Tests', () => {
]);
await saveEnvironment(page);
await closeEnvironmentPanel(page);
await expect(locators.environment.currentEnvironment()).toContainText('Test Global Environment');
});

View File

@@ -36,14 +36,13 @@ test.describe.serial('Collection Environment Export Tests', () => {
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
// Verify the environment settings modal opens
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Open export modal and configure export settings', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Verify export modal opens
const exportModal = page.locator('.bruno-modal').filter({ hasText: 'Export Environments' });
@@ -63,8 +62,6 @@ test.describe.serial('Collection Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export the environment
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
@@ -95,11 +92,14 @@ test.describe.serial('Collection Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for multiple environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Verify all environments are selected by default
await expect(page.getByRole('checkbox', { name: 'Local' })).toBeChecked();
@@ -115,8 +115,6 @@ test.describe.serial('Collection Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export all environments
await page.getByRole('button', { name: /Export \d+ Environments?/ }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported files and content', async () => {
@@ -162,11 +160,14 @@ test.describe.serial('Collection Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings with folder format', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Select folder export format (default might be single JSON file)
await page.getByText('Separate files in folder').click();
@@ -178,8 +179,6 @@ test.describe.serial('Collection Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 2 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify unique naming and file content', async () => {
@@ -219,11 +218,13 @@ test.describe.serial('Collection Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
@@ -244,8 +245,6 @@ test.describe.serial('Collection Environment Export Tests', () => {
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
@@ -274,11 +273,13 @@ test.describe.serial('Collection Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Select single JSON file format
await page.getByText('Single JSON file').click();
@@ -293,8 +294,6 @@ test.describe.serial('Collection Environment Export Tests', () => {
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
@@ -329,11 +328,13 @@ test.describe.serial('Collection Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
@@ -351,8 +352,6 @@ test.describe.serial('Collection Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify unique naming and file content', async () => {
@@ -387,11 +386,14 @@ test.describe.serial('Collection Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Open export modal and deselect all environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.getByRole('button', { name: 'Export Environment' }).click();
// Deselect all environments
await page.getByText('Deselect All').click();

View File

@@ -40,14 +40,13 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
// Verify the global environment settings modal opens
const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(globalEnvModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Open export modal and configure export settings', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Verify export modal opens
const exportModal = page.locator('.bruno-modal').filter({ hasText: 'Export Environments' });
@@ -67,8 +66,6 @@ test.describe.serial('Global Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export the environment
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
@@ -99,11 +96,14 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for multiple environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Verify all environments are selected by default
await expect(page.getByRole('checkbox', { name: 'Local' })).toBeChecked();
@@ -119,8 +119,6 @@ test.describe.serial('Global Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export all environments
await page.getByRole('button', { name: /Export \d+ Environments?/ }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported files and content', async () => {
@@ -166,11 +164,14 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings with folder format', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
@@ -182,8 +183,6 @@ test.describe.serial('Global Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 2 Environment' }).click();
await page.getByTestId('modal-close-button').first().click();
});
await test.step('Verify unique naming and file content', async () => {
@@ -223,11 +222,13 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
@@ -250,8 +251,6 @@ test.describe.serial('Global Environment Export Tests', () => {
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
@@ -280,11 +279,13 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Select single JSON file format
await page.getByText('Single JSON file').click();
@@ -299,8 +300,6 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.waitForTimeout(200);
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
@@ -335,11 +334,13 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.locator('button[title="Export environment"]').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
@@ -359,8 +360,6 @@ test.describe.serial('Global Environment Export Tests', () => {
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').first().click();
});
await test.step('Verify unique naming and file content', async () => {
@@ -395,18 +394,18 @@ test.describe.serial('Global Environment Export Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Open export modal and deselect all environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
await page.getByRole('button', { name: 'Export Environment' }).click();
// Deselect all environments
await page.getByText('Deselect All').click();
});
await test.step('Verify export button is disabled when no environments selected', async () => {
// Verify export button is disabled
await expect(page.getByRole('button', { name: 'Export Environments' })).toBeDisabled();
});
});

View File

@@ -21,15 +21,13 @@ test.describe('Global Environment Configuration Selection Tests', () => {
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
// Verify the config modal opens with the currently active environment selected
const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(globalEnvModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
// Check that the active environment in the config matches the current environment
const activeEnvItem = globalEnvModal.locator('.environment-item.active');
const activeEnvItem = page.locator('.environment-item.active');
await expect(activeEnvItem).toContainText(currentEnvName);
// Close the global environment config modal and go to welcome page
await page.getByText('×').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});

View File

@@ -39,16 +39,14 @@ test.describe.serial('Collection Environment Import Tests', () => {
});
await test.step('Verify imported environment and variables', async () => {
// The environment settings modal should now be visible with the imported environment
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
// Verify imported variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Clean up after test', async () => {
@@ -93,15 +91,11 @@ test.describe.serial('Collection Environment Import Tests', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(multiEnvFile);
// The environment settings modal should now be visible with the imported environments
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Verify both environments are available in selector', async () => {
// Check that both environments are available in the selector
await page.getByText('×').click(); // Close environment settings modal
await page.waitForTimeout(500);
await page.getByTestId('environment-selector-trigger').click();
@@ -118,15 +112,16 @@ test.describe.serial('Collection Environment Import Tests', () => {
// Verify prod environment variables by opening settings again
await page.getByTestId('environment-selector-trigger').click();
await page.getByText('Configure', { exact: true }).click();
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
// Verify prod environment variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Clean up after test', async () => {

View File

@@ -23,12 +23,10 @@ test.describe.serial('Global Environment Import Tests', () => {
// Delete all existing environments
for (let i = 0; i < count; i++) {
await page.getByTestId('delete-environment-button').click();
// Confirm deletion if there's a confirmation dialog
await page.locator('button[title="Delete"]').first().click();
const confirmButton = page.getByRole('button', { name: 'Delete' });
if (await confirmButton.isVisible()) {
await confirmButton.click();
await page.getByText('×').click();
}
}
@@ -56,16 +54,15 @@ test.describe.serial('Global Environment Import Tests', () => {
});
await test.step('Verify imported global environment and variables', async () => {
// The global environment settings modal should now be visible with the imported environment
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(envModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
// Verify imported variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});
@@ -90,12 +87,10 @@ test.describe.serial('Global Environment Import Tests', () => {
// Delete all existing environments
for (let i = 0; i < count; i++) {
await page.getByTestId('delete-environment-button').click();
// Confirm deletion if there's a confirmation dialog
const confirmButton = page.getByRole('button', { name: 'Delete' });
await page.locator('button[title="Delete"]').first().click();
const confirmButton = page.getByText('Delete', { exact: true });
if (await confirmButton.isVisible()) {
await confirmButton.click();
await page.getByText('×').click();
}
}
@@ -120,14 +115,11 @@ test.describe.serial('Global Environment Import Tests', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(multiEnvFile);
// The global environment settings modal should now be visible with the imported environments
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(envModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
await test.step('Verify both global environments are available in selector', async () => {
// Check that both environments are available in the selector
await page.getByText('×').click(); // Close environment settings modal
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
@@ -145,15 +137,15 @@ test.describe.serial('Global Environment Import Tests', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(envModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
// Verify imported variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});
});

View File

@@ -52,21 +52,19 @@ test.describe('Collection Environment Import Tests', () => {
// Wait for import to complete and environment settings modal to open
await expect(page.locator('.current-environment')).toContainText('Test Collection Environment');
// The environment settings modal should now be visible with the imported environment
const envSettingsModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envSettingsModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
// Verify imported variables in Test Collection Environment settings
await expect(envSettingsModal.locator('input[name="0.name"]')).toHaveValue('host');
await expect(envSettingsModal.locator('input[name="1.name"]')).toHaveValue('userId');
await expect(envSettingsModal.locator('input[name="2.name"]')).toHaveValue('apiKey');
await expect(envSettingsModal.locator('input[name="3.name"]')).toHaveValue('postTitle');
await expect(envSettingsModal.locator('input[name="4.name"]')).toHaveValue('postBody');
await expect(envSettingsModal.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
await expect(envSettingsModal.locator('input[name="5.secret"]')).toBeChecked();
await page.getByText('×').click();
await expect(page.locator('input[name="0.name"]')).toHaveValue('host');
await expect(page.locator('input[name="1.name"]')).toHaveValue('userId');
await expect(page.locator('input[name="2.name"]')).toHaveValue('apiKey');
await expect(page.locator('input[name="3.name"]')).toHaveValue('postTitle');
await expect(page.locator('input[name="4.name"]')).toHaveValue('postBody');
await expect(page.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
await expect(page.locator('input[name="5.secret"]')).toBeChecked();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
// Test GET request with imported environment
await page.locator('.collection-item-name').first().click();
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}');
await page.locator('[data-testid="send-arrow-icon"]').click();

View File

@@ -47,21 +47,20 @@ test.describe('Global Environment Import Tests', () => {
// Wait for import to complete and global environment settings modal to open
await expect(page.locator('.current-environment')).toContainText('Test Global Environment');
// The global environment settings modal should now be visible with the imported environment
const globalEnvSettingsModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(globalEnvSettingsModal).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
// Verify imported variables in Test Global Environment settings
await expect(globalEnvSettingsModal.locator('input[name="0.name"]')).toHaveValue('host');
await expect(globalEnvSettingsModal.locator('input[name="1.name"]')).toHaveValue('userId');
await expect(globalEnvSettingsModal.locator('input[name="2.name"]')).toHaveValue('apiKey');
await expect(globalEnvSettingsModal.locator('input[name="3.name"]')).toHaveValue('postTitle');
await expect(globalEnvSettingsModal.locator('input[name="4.name"]')).toHaveValue('postBody');
await expect(globalEnvSettingsModal.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
await expect(globalEnvSettingsModal.locator('input[name="5.secret"]')).toBeChecked();
await page.getByText('×').click();
const variablesTable = page.locator('.table-container');
await expect(variablesTable.locator('input[name="0.name"]')).toHaveValue('host');
await expect(variablesTable.locator('input[name="1.name"]')).toHaveValue('userId');
await expect(variablesTable.locator('input[name="2.name"]')).toHaveValue('apiKey');
await expect(variablesTable.locator('input[name="3.name"]')).toHaveValue('postTitle');
await expect(variablesTable.locator('input[name="4.name"]')).toHaveValue('postBody');
await expect(variablesTable.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
await expect(variablesTable.locator('input[name="5.secret"]')).toBeChecked();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
// Test GET request with global environment
await page.locator('#collection-environment-test-collection .collection-item-name').first().click();
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}');
await page.locator('[data-testid="send-arrow-icon"]').click();

View File

@@ -11,7 +11,7 @@ test.describe('Multiline Variables - Write Test', () => {
// open request
await expect(page.getByTitle('multiline-test', { exact: true })).toBeVisible();
await page.getByTitle('multiline-test', { exact: true }).click();
await page.getByTitle('multiline-test', { exact: true }).dblclick();
// open environment dropdown
await page.locator('div.current-environment').click();
@@ -28,10 +28,12 @@ test.describe('Multiline Variables - Write Test', () => {
await expect(page.getByText('Configure', { exact: true })).toBeVisible();
await page.getByText('Configure', { exact: true }).click();
// add variable
await page.getByRole('button', { name: /Add.*Variable/i }).click();
const valueTextarea = page.locator('.bruno-modal-card textarea').last();
await expect(valueTextarea).toBeVisible();
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await expect(envTab).toBeVisible();
const emptyRowNameInput = page.locator('tbody tr').last().locator('input[placeholder="Name"]');
await expect(emptyRowNameInput).toBeVisible();
await emptyRowNameInput.fill('multiline_data_json');
const jsonValue = `{
"user": {
@@ -48,23 +50,17 @@ test.describe('Multiline Variables - Write Test', () => {
}
}`;
// fill variable value
await valueTextarea.fill(jsonValue);
await page.keyboard.press('Shift+Tab');
await page.keyboard.type('multiline_data_json');
const variableRow = page.locator('tbody tr').filter({ has: page.locator('input[value="multiline_data_json"]') });
const codeMirror = variableRow.locator('.CodeMirror');
await codeMirror.click();
await page.keyboard.insertText(jsonValue);
// save variable and close config
const saveVarButton = page.getByRole('button', { name: /Save/i });
await expect(saveVarButton).toBeVisible();
await saveVarButton.click();
await page.getByTestId('save-env').click();
await expect(page.locator('.close.cursor-pointer')).toBeVisible();
await page.locator('.close.cursor-pointer').click();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
// send request
const sendButton = page.locator('#send-request').getByRole('img').nth(2);
await expect(sendButton).toBeVisible();
await sendButton.click();
await page.getByTestId('send-arrow-icon').click();
// wait for response status
await expect(page.locator('.response-status-code.text-ok')).toBeVisible();

View File

@@ -3,7 +3,6 @@ import { closeAllCollections } from '../../utils/page';
test.describe('Global Environment Variable Update via Script', () => {
test.afterEach(async ({ pageWithUserData: page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
@@ -23,40 +22,43 @@ test.describe('Global Environment Variable Update via Script', () => {
await page.getByTestId('send-arrow-icon').click();
});
await test.step('Open the Global Environment Config modal', async () => {
await test.step('Open the Global Environment Config tab', async () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await test.step('Verify that the value of "existingEnvEnabled" is updated by the pre-request script', async () => {
const updatedExistingEnvEnabledInputDiv = await globalEnvModal.getByTestId('env-var-value-1');
const updatedExistingEnvEnabledValue = await updatedExistingEnvEnabledInputDiv.locator('.CodeMirror-line').textContent();
await expect(updatedExistingEnvEnabledValue).toContain('newExistingEnvEnabledValue');
const row = page.locator('tbody tr').filter({ has: page.locator('input[value="existingEnvEnabled"]') });
const value = await row.locator('.CodeMirror-line').first().textContent();
await expect(value).toContain('newExistingEnvEnabledValue');
});
await test.step('Verify that the value of "existingEnvDisabled" is updated by the pre-request script', async () => {
const updatedExistingEnvDisabledInputDiv = await globalEnvModal.getByTestId('env-var-value-2');
const updatedExistingEnvDisabledValue = await updatedExistingEnvDisabledInputDiv.locator('.CodeMirror-line').textContent();
await expect(updatedExistingEnvDisabledValue).toContain('newExistingEnvDisabledValue');
const row = page.locator('tbody tr').filter({ has: page.locator('input[value="existingEnvDisabled"]') });
const value = await row.locator('.CodeMirror-line').first().textContent();
await expect(value).toContain('newExistingEnvDisabledValue');
});
await test.step('Verify that a new env variable "newEnv" is added by the pre-request script to the global environment', async () => {
const newEnvInputDiv = await globalEnvModal.getByTestId('env-var-value-3');
const newEnvValue = await newEnvInputDiv.locator('.CodeMirror-line').textContent();
await expect(newEnvValue).toContain('newEnvValue');
const row = page.locator('tbody tr').filter({ has: page.locator('input[value="newEnv"]') });
const value = await row.locator('.CodeMirror-line').first().textContent();
await expect(value).toContain('newEnvValue');
});
await test.step('Verify that the value of "baseUrl" is unchanged.', async () => {
const currentBaseUrlInputDiv = await globalEnvModal.getByTestId('env-var-value-0');
const currentBaseUrlValue = await currentBaseUrlInputDiv.locator('.CodeMirror-line').textContent();
await expect(currentBaseUrlValue).toContain('https://echo.usebruno.com');
const row = page.locator('tbody tr').filter({ has: page.locator('input[value="baseUrl"]') });
const value = await row.locator('.CodeMirror-line').first().textContent();
await expect(value).toContain('https://echo.usebruno.com');
});
await test.step('Close the global environment config modal.', async () => {
await page.getByTestId('modal-close-button').click();
await test.step('Close the global environment config tab.', async () => {
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});
});

View File

@@ -1,5 +1,5 @@
import { test, expect } from '../../playwright';
import { openCollectionAndAcceptSandbox, closeAllCollections, sendRequest } from '../utils/page';
import { openCollectionAndAcceptSandbox, closeAllCollections, sendRequest, addEnvironmentVariables } from '../utils/page';
import { buildCommonLocators } from '../utils/page/locators';
test.describe('Global Environment Variables - Non-string Values', () => {
@@ -23,20 +23,19 @@ test.describe('Global Environment Variables - Non-string Values', () => {
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');
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
await addEnvironmentVariables(page, [
{ name: 'stringVar', value: 'hello world' },
{ name: 'numericVar', value: '170001' },
{ name: 'booleanVar', value: 'true' }
]);
// 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();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
// Request contains a script that sets the non-string global variables.
@@ -50,14 +49,13 @@ test.describe('Global Environment Variables - Non-string Values', () => {
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByRole('button', { name: 'Configure' }).click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await expect(envTab).toBeVisible();
});
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"]');
const numericInput = page.locator('input[value="numericVar"]');
const booleanInput = page.locator('input[value="booleanVar"]');
await expect(numericInput).toBeVisible();
await expect(booleanInput).toBeVisible();
const numericRow = numericInput.locator('xpath=ancestor::tr');
@@ -74,9 +72,7 @@ test.describe('Global Environment Variables - Non-string Values', () => {
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');
const infoIcon = numericRow.locator('[id$="-disabled-info-icon"]').nth(0);
await infoIcon.hover();
// The tooltip explains why the field is locked.
@@ -103,8 +99,7 @@ test.describe('Global Environment Variables - Non-string Values', () => {
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');
const infoIcon = booleanRow.locator('[id$="-disabled-info-icon"]').nth(0);
await infoIcon.hover();
// The tooltip explains why the field is locked.
@@ -114,8 +109,7 @@ test.describe('Global Environment Variables - Non-string Values', () => {
});
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"]');
const stringInput = page.locator('input[value="stringVar"]');
await expect(stringInput).toBeVisible();
const stringRow = stringInput.locator('xpath=ancestor::tr');
@@ -125,8 +119,12 @@ test.describe('Global Environment Variables - Non-string Values', () => {
// 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();
await page.getByTestId('save-env').click();
const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});
});

View File

@@ -178,9 +178,10 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => {
await expect(page.getByTestId('env-var-row-newFeature.version').locator('.CodeMirror-line').first()).toHaveText('2.099123123');
});
await test.step('Close environment modal', async () => {
// Close the environment configuration modal to ensure clean state
await page.getByText('×').click();
await test.step('Close environment tab', async () => {
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});
});

View File

@@ -202,9 +202,10 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => {
await expect(page.getByTestId('env-var-row-user.roles[0]').locator('.CodeMirror-line').first()).toHaveText('admin');
});
await test.step('Close environment modal', async () => {
// Close the environment configuration modal to ensure clean state
await page.getByText('×').click();
await test.step('Close environment tab', async () => {
const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
});
});

View File

@@ -16,6 +16,8 @@ test('should persist request with newlines across app restarts', async ({ create
await page.locator('.bruno-modal').getByLabel('Location').fill(collectionPath);
await page.locator('.bruno-modal').getByRole('button', { name: 'Create' }).click();
await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe');
const collection = page.getByTestId('collections').locator('.collection-name').filter({ hasText: 'newlines-persistence' });
await collection.hover();
await collection.locator('.collection-actions .icon').click();
@@ -25,7 +27,6 @@ test('should persist request with newlines across app restarts', async ({ create
await page.locator('#new-request-url').locator('textarea').fill('https://httpbin.org/get');
await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe');
await page.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick();
await page.getByRole('tab', { name: 'Params' }).click();

View File

@@ -390,10 +390,23 @@ const createEnvironment = async (
await page.locator('button[id="create-env"]').click();
const nameInput = page.locator('input[name="name"]');
const nameInput = type === 'collection'
? page.locator('input[name="name"]')
: page.locator('#environment-name');
await expect(nameInput).toBeVisible();
await nameInput.fill(environmentName);
await page.getByRole('button', { name: 'Create' }).click();
const tabLabel = type === 'collection' ? 'Environments' : 'Global Environments';
await expect(page.locator('.request-tab').filter({ hasText: tabLabel })).toBeVisible();
const locators = buildCommonLocators(page);
await locators.environment.selector().click();
if (type === 'global') {
await locators.environment.globalTab().click();
}
await locators.environment.envOption(environmentName).click();
await expect(page.locator('.current-environment')).toContainText(environmentName);
});
};
@@ -416,11 +429,6 @@ const addEnvironmentVariable = async (
index: number
) => {
await test.step(`Add environment variable "${variable.name}"`, async () => {
const addButton = page.locator('button[data-testid="add-variable"]');
await addButton.waitFor({ state: 'visible' });
await addButton.click();
// Wait for the new row to be added and the name input to be visible
const nameInput = page.locator(`input[name="${index}.name"]`);
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(variable.name);
@@ -466,13 +474,17 @@ const saveEnvironment = async (page: Page) => {
};
/**
* Close the environment modal/panel
* Close the environment tab
* @param page - The page object
* @param type - The type of environment tab to close
* @returns void
*/
const closeEnvironmentPanel = async (page: Page) => {
await test.step('Close environment panel', async () => {
await page.getByText('×').click();
const closeEnvironmentPanel = async (page: Page, type: EnvironmentType = 'collection') => {
await test.step('Close environment tab', async () => {
const tabLabel = type === 'collection' ? 'Environments' : 'Global Environments';
const envTab = page.locator('.request-tab').filter({ hasText: tabLabel });
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
};

View File

@@ -39,7 +39,7 @@ export const buildCommonLocators = (page: Page) => ({
tabs: {
requestTab: (requestName: string) => page.locator('.request-tab .tab-label').filter({ hasText: requestName }),
activeRequestTab: () => page.locator('.request-tab.active'),
closeTab: (requestName: string) => page.locator('.request-tab').filter({ hasText: requestName }).locator('.close-icon')
closeTab: (requestName: string) => page.locator('.request-tab').filter({ hasText: requestName }).getByTestId('request-tab-close-icon')
},
folder: {
chevron: (folderName: string) => page.locator('.collection-item-name').filter({ hasText: folderName }).getByTestId('folder-chevron')