Merge pull request #3222 from lohxt1/feat/global-environments

feat: global env
This commit is contained in:
lohit 2024-10-08 10:28:30 +05:30 committed by GitHub
commit 4211575f01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1705 additions and 20 deletions

View File

@ -73,7 +73,9 @@ if (!SERVER_RENDERED) {
'bru.setNextRequest(requestName)',
'req.disableParsingResponseJson()',
'bru.getRequestVar(key)',
'bru.sleep(ms)'
'bru.sleep(ms)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();

View File

@ -36,6 +36,13 @@ const Wrapper = styled.div`
padding: 0.35rem 0.6rem;
cursor: pointer;
&.active {
color: ${(props) => props.theme.colors.text.yellow} !important;
.icon {
color: ${(props) => props.theme.colors.text.yellow} !important;
}
}
.icon {
color: ${(props) => props.theme.dropdown.iconColor};
}

View File

@ -2,9 +2,9 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
return (
<StyledWrapper className="dropdown">
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
content={children}
placement={placement || 'bottom-end'}

View File

@ -53,10 +53,11 @@ const EnvironmentSelector = ({ collection }) => {
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item font-medium">Collection Environments</div>
{environments && environments.length
? environments.map((e) => (
<div
className="dropdown-item"
className={`dropdown-item ${e?.uid === activeEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);

View File

@ -59,7 +59,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
const ErrorMessage = ({ name }) => {
const meta = formik.getFieldMeta(name);
if (!meta.error) {
if (!meta.error || !meta.touched) {
return null;
}

View File

@ -0,0 +1,18 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
}
.environment-active {
padding: 0.3rem 0.4rem;
color: ${(props) => props.theme.colors.text.yellow};
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
}
.environment-selector {
.active: {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default Wrapper;

View File

@ -0,0 +1,94 @@
import React, { useRef, forwardRef, useState } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconSettings, IconWorld, IconDatabase, IconDatabaseOff, IconCheck } from '@tabler/icons';
import EnvironmentSettings from '../EnvironmentSettings';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const EnvironmentSelector = () => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const activeEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null;
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className={`current-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
<IconWorld className="globe" size={16} strokeWidth={1.5} />
{
activeEnvironment ? <div>{activeEnvironment?.name}</div> : null
}
</div>
);
});
const handleSettingsIconClick = () => {
setOpenSettingsModal(true);
};
const handleModalClose = () => {
setOpenSettingsModal(false);
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const onSelect = (environment) => {
dispatch(selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
return (
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector mr-3">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end" transparent={true}>
<div className="label-item font-medium">Global Environments</div>
{globalEnvironments && globalEnvironments.length
? globalEnvironments.map((e) => (
<div
className={`dropdown-item ${e?.uid === activeGlobalEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);
dropdownTippyRef.current.hide();
}}
>
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
</div>
))
: null}
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onSelect(null);
}}
>
<IconDatabaseOff size={18} strokeWidth={1.5} />
<span className="ml-2">No Environment</span>
</div>
<div className="dropdown-item border-top" onClick={handleSettingsIconClick}>
<div className="pr-2 text-gray-600">
<IconSettings size={18} strokeWidth={1.5} />
</div>
<span>Configure</span>
</div>
</Dropdown>
</div>
{openSettingsModal && <EnvironmentSettings globalEnvironments={globalEnvironments} activeGlobalEnvironmentUid={activeGlobalEnvironmentUid} onClose={handleModalClose} />}
</StyledWrapper>
);
};
export default EnvironmentSelector;

View File

@ -0,0 +1,78 @@
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-semibold">
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

@ -0,0 +1,83 @@
import React, { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const CreateEnvironment = ({ onClose }) => {
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(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(addGlobalEnvironment({ name: values.name }))
.then(() => {
toast.success('Global environment created!');
onClose();
})
.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-semibold">
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

@ -0,0 +1,15 @@
import styled from 'styled-components';
const Wrapper = styled.div`
button.submit {
color: white;
background-color: var(--color-background-danger) !important;
border: inherit !important;
&:hover {
border: inherit !important;
}
}
`;
export default Wrapper;

View File

@ -0,0 +1,37 @@
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-semibold">{environment.name}</span> ?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteEnvironment;

View File

@ -0,0 +1,42 @@
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-semibold">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

@ -0,0 +1,61 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
&: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: 0.8125rem;
user-select: none;
}
thead td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: 0.8125rem;
}
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;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@ -0,0 +1,195 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
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';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
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.string().trim().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);
if (!meta.error || !meta.touched) {
return null;
}
return (
<label htmlFor={name} className="text-red-500">
{meta.error}
</label>
);
};
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}>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
</td>
<td>
<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`} />
</td>
<td className="flex flex-row flex-nowrap">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
</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}
>
+ Add Variable
</button>
</div>
</div>
<div>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
Reset
</button>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariables;

View File

@ -0,0 +1,46 @@
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';
const EnvironmentDetails = ({ environment, setIsModified }) => {
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-semibold break-all">{environment.name}</span>
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
</div>
</div>
<div>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
</div>
</div>
);
};
export default EnvironmentDetails;

View File

@ -0,0 +1,58 @@
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;
&: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

@ -0,0 +1,138 @@
import React, { useEffect, useState } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
import ImportEnvironment from '../ImportEnvironment';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
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 (selectedEnvironment) {
setOriginalEnvironmentVariables(selectedEnvironment.variables);
return;
}
const environment = environments?.find(env => env?.uid === activeEnvironmentUid);
if (environment) {
setSelectedEnvironment(environment);
} else {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [environments, selectedEnvironment]);
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 handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
}
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment 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) => (
<div
key={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>
))}
<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={() => 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}
/>
</div>
</StyledWrapper>
);
};
export default EnvironmentList;

View File

@ -0,0 +1,62 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { uuid } from 'utils/common/index';
const ImportEnvironment = ({ onClose }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
.then((environments) => {
environments
.filter((env) =>
env.name && env.name !== 'undefined'
? true
: () => {
toast.error('Failed to import environment: env has no name');
return false;
}
)
.map((environment) => {
let variables = environment?.variables?.map(v => ({
...v,
uid: uuid(),
type: 'text'
}));
dispatch(addGlobalEnvironment({ name: environment.name, variables }))
.then(() => {
toast.success('Global Environment imported successfully');
})
.catch(() => toast.error('An error occurred while importing the environment'));
});
})
.then(() => {
onClose();
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
</button>
</Modal>
</Portal>
);
};
export default ImportEnvironment;

View File

@ -0,0 +1,88 @@
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 { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
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(50, 'must be 50 characters or less')
.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-semibold">
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

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

View File

@ -0,0 +1,78 @@
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 ImportEnvironment from './ImportEnvironment/index';
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-semibold 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-semibold 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, activeGlobalEnvironmentUid, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
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' ? (
<ImportEnvironment onClose={() => setTab('default')} />
) : (
<></>
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);
}
return (
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
/>
</Modal>
);
};
export default EnvironmentSettings;

View File

@ -20,6 +20,8 @@ import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { cloneDeep } from 'lodash';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@ -34,6 +36,7 @@ const RequestTabPanel = () => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const collections = useSelector((state) => state.collections.collections);
const screenWidth = useSelector((state) => state.app.screenWidth);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
@ -117,7 +120,14 @@ const RequestTabPanel = () => {
return <div className="pb-4 px-4">An error occurred!</div>;
}
let collection = find(collections, (c) => c.uid === focusedTab.collectionUid);
let _collection = find(collections, (c) => c.uid === focusedTab.collectionUid);
let collection = cloneDeep(_collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
if (!collection || !collection.uid) {
return <div className="pb-4 px-4">Collection not found!</div>;
}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { uuid } from 'utils/common';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import GlobalEnvironmentSelector from 'components/GlobalEnvironments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
@ -48,7 +49,7 @@ const CollectionToolBar = ({ collection }) => {
<IconFiles size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">{collection?.name}</span>
</div>
<div className="flex flex-1 items-center justify-end">
<div className="flex flex-3 items-center justify-end">
<span className="mr-2">
<JsSandboxMode collection={collection} />
</span>
@ -67,6 +68,7 @@ const CollectionToolBar = ({ collection }) => {
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<GlobalEnvironmentSelector />
<EnvironmentSelector collection={collection} />
</div>
</div>

View File

@ -8,19 +8,24 @@ import { useSelector } from 'react-redux';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import toast from 'react-hot-toast';
import { IconCopy } from '@tabler/icons';
import { findCollectionByItemUid } from '../../../../../../../utils/collections/index';
import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index';
import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth';
const CodeView = ({ language, item }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const { target, client, language: lang } = language;
const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const collection = findCollectionByItemUid(
let collection = findCollectionByItemUid(
useSelector((state) => state.collections.collections),
item.uid
);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
const collectionRootAuth = collection?.root?.request?.auth;
const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');

View File

@ -23,6 +23,7 @@ import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUi
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
const useIpcEvents = () => {
const dispatch = useDispatch();
@ -109,6 +110,10 @@ const useIpcEvents = () => {
dispatch(scriptEnvironmentUpdateEvent(val));
});
const removeGlobalEnvironmentVariablesUpdateListener = ipcRenderer.on('main:global-environment-variables-update', (val) => {
dispatch(globalEnvironmentsUpdateEvent(val));
});
const removeCollectionRenamedListener = ipcRenderer.on('main:collection-renamed', (val) => {
dispatch(collectionRenamedEvent(val));
});
@ -149,6 +154,10 @@ const useIpcEvents = () => {
dispatch(updateCookies(val));
});
const removeGlobalEnvironmentsUpdatesListener = ipcRenderer.on('main:load-global-environments', (val) => {
dispatch(updateGlobalEnvironments(val));
});
const removeSnapshotHydrationListener = ipcRenderer.on('main:hydrate-app-with-ui-state-snapshot', (val) => {
dispatch(hydrateCollectionWithUiStateSnapshot(val));
})
@ -159,6 +168,7 @@ const useIpcEvents = () => {
removeCollectionAlreadyOpenedListener();
removeDisplayErrorListener();
removeScriptEnvUpdateListener();
removeGlobalEnvironmentVariablesUpdateListener();
removeCollectionRenamedListener();
removeRunFolderEventListener();
removeRunRequestEventListener();
@ -169,6 +179,7 @@ const useIpcEvents = () => {
removePreferencesUpdatesListener();
removeCookieUpdateListener();
removeSystemProxyEnvUpdatesListener();
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
};
}, [isElectron]);

View File

@ -6,6 +6,7 @@ import appReducer from './slices/app';
import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
const { publicRuntimeConfig } = getConfig();
const isDevEnv = () => {
@ -22,7 +23,8 @@ export const store = configureStore({
app: appReducer,
collections: collectionsReducer,
tabs: tabsReducer,
notifications: notificationsReducer
notifications: notificationsReducer,
globalEnvironments: globalEnvironmentsReducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
});

View File

@ -44,6 +44,7 @@ import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { name } from 'file-loader';
import slash from 'utils/common/slash';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
@ -184,6 +185,7 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@ -191,7 +193,11 @@ export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
let collectionCopy = cloneDeep(collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
@ -213,6 +219,7 @@ export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch
export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@ -221,7 +228,11 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
}
const itemCopy = cloneDeep(item || {});
const collectionCopy = cloneDeep(collection);
let collectionCopy = cloneDeep(collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables)
@ -286,6 +297,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@ -293,7 +305,12 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
let collectionCopy = cloneDeep(collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const folder = findItemInCollection(collectionCopy, folderUid);
if (folderUid && !folder) {

View File

@ -0,0 +1,242 @@
import { createSlice } from '@reduxjs/toolkit';
import { stringifyIfNot, uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
import { cloneDeep } from 'lodash';
const initialState = {
globalEnvironments: [],
activeGlobalEnvironmentUid: null
};
export const globalEnvironmentsSlice = createSlice({
name: 'app',
initialState,
reducers: {
updateGlobalEnvironments: (state, action) => {
state.globalEnvironments = action.payload?.globalEnvironments;
state.activeGlobalEnvironmentUid = action.payload?.activeGlobalEnvironmentUid;
},
_addGlobalEnvironment: (state, action) => {
const { name, uid, variables = [] } = action.payload;
if (name?.length) {
state.globalEnvironments.push({
uid,
name,
variables
});
}
},
_saveGlobalEnvironment: (state, action) => {
const { environmentUid: globalEnvironmentUid, variables } = action.payload;
if (globalEnvironmentUid) {
const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
if (environment) {
environment.variables = variables;
}
}
},
_renameGlobalEnvironment: (state, action) => {
const { environmentUid: globalEnvironmentUid, name } = action.payload;
if (globalEnvironmentUid) {
const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
if (environment) {
environment.name = name;
}
}
},
_copyGlobalEnvironment: (state, action) => {
const { name, uid, variables } = action.payload;
if (name?.length && uid) {
state.globalEnvironments.push({
uid,
name,
variables
});
}
},
_selectGlobalEnvironment: (state, action) => {
const { environmentUid: globalEnvironmentUid } = action.payload;
if (globalEnvironmentUid) {
const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
if (environment) {
state.activeGlobalEnvironmentUid = globalEnvironmentUid;
}
} else {
state.activeGlobalEnvironmentUid = null;
}
},
_deleteGlobalEnvironment: (state, action) => {
const { environmentUid: uid } = action.payload;
if (uid) {
state.globalEnvironments = state.globalEnvironments.filter(env => env?.uid !== uid);
if (uid === state.activeGlobalEnvironmentUid) {
state.activeGlobalEnvironmentUid = null;
}
}
}
}
});
export const {
updateGlobalEnvironments,
_addGlobalEnvironment,
_saveGlobalEnvironment,
_renameGlobalEnvironment,
_copyGlobalEnvironment,
_selectGlobalEnvironment,
_deleteGlobalEnvironment
} = globalEnvironmentsSlice.actions;
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const uid = uuid();
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(
dispatch(_addGlobalEnvironment({ name, uid, variables }))
)
.then(resolve)
.catch(reject);
});
};
export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
const uid = uuid();
ipcRenderer
.invoke('renderer:create-global-environment', { name, variables: baseEnv.variables })
.then(() => {
dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables }))
})
.then(resolve)
.catch(reject);
});
};
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
if (!environment) {
return reject(new Error('Environment not found'));
}
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid }))
.then(
dispatch(_renameGlobalEnvironment({ name: newName, environmentUid }))
)
.then(resolve)
.catch(reject);
});
};
export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid);
if (!environment) {
return reject(new Error('Environment not found'));
}
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
environmentUid,
variables
}))
.then(
dispatch(_saveGlobalEnvironment({ environmentUid, variables }))
)
.then(resolve)
.catch((error) => {
reject(error);
});
});
};
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:select-global-environment', { environmentUid })
.then(
dispatch(_selectGlobalEnvironment({ environmentUid }))
)
.then(resolve)
.catch(reject);
});
};
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:delete-global-environment', { environmentUid })
.then(
dispatch(_deleteGlobalEnvironment({ environmentUid }))
)
.then(resolve)
.catch(reject);
});
};
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!globalEnvironmentVariables) resolve();
const state = getState();
const globalEnvironments = state?.globalEnvironments?.globalEnvironments || [];
const environmentUid = state?.globalEnvironments?.activeGlobalEnvironmentUid;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid);
if (!environment || !environmentUid) {
console.error('Global Environment not found');
return resolve();
}
let variables = cloneDeep(environment?.variables);
// update existing values
variables = variables?.map?.(variable => ({
...variable,
value: stringifyIfNot(globalEnvironmentVariables?.[variable?.name])
}));
// add new env values
Object.entries(globalEnvironmentVariables)?.forEach?.(([key, value]) => {
let isAnExistingVariable = variables?.find(v => v?.name == key)
if (!isAnExistingVariable) {
variables.push({
uid: uuid(),
name: key,
value: stringifyIfNot(value),
type: 'text',
secret: false,
enabled: true
});
}
});
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
environmentUid,
variables
}))
.then(
dispatch(_saveGlobalEnvironment({ environmentUid, variables }))
)
.then(resolve)
.catch((error) => {
reject(error);
});
});
}
export default globalEnvironmentsSlice.reducer;

View File

@ -786,6 +786,19 @@ export const getDefaultRequestPaneTab = (item) => {
}
};
export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobalEnvironmentUid }) => {
let variables = {};
const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid);
if (environment) {
each(environment.variables, (variable) => {
if (variable.name && variable.value && variable.enabled) {
variables[variable.name] = variable.value;
}
});
}
return variables;
};
export const getEnvironmentVariables = (collection) => {
let variables = {};
if (collection) {
@ -802,6 +815,7 @@ export const getEnvironmentVariables = (collection) => {
return variables;
};
const getPathParams = (item) => {
let pathParams = {};
if (item && item.request && item.request.params) {
@ -828,14 +842,17 @@ export const getTotalRequestCountInCollection = (collection) => {
};
export const getAllVariables = (collection, item) => {
if(!collection) return {};
const envVariables = getEnvironmentVariables(collection);
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
let { collectionVariables, folderVariables, requestVariables } = mergeVars(collection, requestTreePath);
const pathParams = getPathParams(item);
const { globalEnvironmentVariables = {} } = collection;
const { processEnvVariables = {}, runtimeVariables = {} } = collection;
return {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,

View File

@ -158,3 +158,11 @@ export const humanizeDate = (dateString) => {
day: 'numeric'
});
};
export const generateUidBasedOnHash = (str) => {
const hash = simpleHash(str);
return `${hash}`.padEnd(21, '0');
};
export const stringifyIfNot = v => typeof v === 'string' ? v : String(v);

View File

@ -23,6 +23,7 @@ const registerPreferencesIpc = require('./ipc/preferences');
const Watcher = require('./app/watcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const registerNotificationsIpc = require('./ipc/notifications');
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
const lastOpenedCollections = new LastOpenedCollections();
@ -143,6 +144,7 @@ app.on('ready', async () => {
// register all ipc handlers
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow);
registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections);
registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections);
registerNotificationsIpc(mainWindow, watcher);

View File

@ -0,0 +1,50 @@
require('dotenv').config();
const { ipcMain } = require('electron');
const { globalEnvironmentsStore } = require('../store/global-environments');
const registerGlobalEnvironmentsIpc = (mainWindow) => {
// GLOBAL ENVIRONMENTS
ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables }) => {
try {
globalEnvironmentsStore.addGlobalEnvironment({ uid, name, variables });
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:save-global-environment', async (event, { environmentUid, variables }) => {
try {
globalEnvironmentsStore.saveGlobalEnvironment({ environmentUid, variables })
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:rename-global-environment', async (event, { environmentUid, name }) => {
try {
globalEnvironmentsStore.renameGlobalEnvironment({ environmentUid, name });
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:delete-global-environment', async (event, { environmentUid }) => {
try {
globalEnvironmentsStore.deleteGlobalEnvironment({ environmentUid });
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:select-global-environment', async (event, { environmentUid }) => {
try {
globalEnvironmentsStore.selectGlobalEnvironment({ environmentUid });
} catch (error) {
return Promise.reject(error);
}
});
};
module.exports = registerGlobalEnvironmentsIpc;

View File

@ -409,6 +409,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
}
// interpolate variables inside request
@ -469,6 +473,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: result.globalEnvironmentVariables
});
}
if (result?.error) {
@ -504,6 +512,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
}
return scriptResult;
};
@ -691,6 +703,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
}
return {
@ -1160,6 +1176,10 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables: testResults.runtimeVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
}
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {

View File

@ -14,6 +14,7 @@ const getContentType = (headers = {}) => {
};
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
@ -39,6 +40,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
// runtimeVariables take precedence over envVars
const combinedVars = {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,

View File

@ -370,6 +370,7 @@ const prepareRequest = (item, collection) => {
mergeFolderLevelHeaders(request, requestTreePath);
mergeFolderLevelScripts(request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
}
// Request level headers
@ -461,6 +462,7 @@ const prepareRequest = (item, collection) => {
axiosRequest.collectionVariables = request.collectionVariables;
axiosRequest.folderVariables = request.folderVariables;
axiosRequest.requestVariables = request.requestVariables;
axiosRequest.globalEnvironmentVariables = request.globalEnvironmentVariables;
axiosRequest.assertions = request.assertions;
return axiosRequest;

View File

@ -2,6 +2,7 @@ const { ipcMain } = require('electron');
const { getPreferences, savePreferences, preferencesUtil } = require('../store/preferences');
const { isDirectory } = require('../utils/filesystem');
const { openCollection } = require('../app/collections');
const { globalEnvironmentsStore } = require('../store/global-environments');
``;
const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
ipcMain.handle('renderer:ready', async (event) => {
@ -9,10 +10,17 @@ const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
const preferences = getPreferences();
mainWindow.webContents.send('main:load-preferences', preferences);
// load system proxy vars
const systemProxyVars = preferencesUtil.getSystemProxyEnvVariables();
const { http_proxy, https_proxy, no_proxy } = systemProxyVars || {};
mainWindow.webContents.send('main:load-system-proxy-env', { http_proxy, https_proxy, no_proxy });
// load global environments
const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
let activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid();
activeGlobalEnvironmentUid = globalEnvironments?.find(env => env?.uid == activeGlobalEnvironmentUid) ? activeGlobalEnvironmentUid : null;
mainWindow.webContents.send('main:load-global-environments', { globalEnvironments, activeGlobalEnvironmentUid });
// reload last opened collections
const lastOpened = lastOpenedCollections.getAll();

View File

@ -0,0 +1,132 @@
const _ = require('lodash');
const Store = require('electron-store');
const { encryptString, decryptString } = require('../utils/encryption');
class GlobalEnvironmentsStore {
constructor() {
this.store = new Store({
name: 'global-environments',
clearInvalidConfig: true
});
}
isValidValue(val) {
return typeof val === 'string' && val.length >= 0;
}
encryptGlobalEnvironmentVariables({ globalEnvironments }) {
return globalEnvironments?.map(env => {
const variables = env.variables?.map(v => ({
...v,
value: v?.secret ? (this.isValidValue(v.value) ? encryptString(v.value) : '') : v?.value
})) || [];
return {
...env,
variables
};
});
}
decryptGlobalEnvironmentVariables({ globalEnvironments }) {
return globalEnvironments?.map(env => {
const variables = env.variables?.map(v => ({
...v,
value: v?.secret ? (this.isValidValue(v.value) ? decryptString(v.value) : '') : v?.value
})) || [];
return {
...env,
variables
};
});
}
getGlobalEnvironments() {
let globalEnvironments = this.store.get('environments', []);
globalEnvironments = this.decryptGlobalEnvironmentVariables({ globalEnvironments });
return globalEnvironments;
}
getActiveGlobalEnvironmentUid() {
return this.store.get('activeGlobalEnvironmentUid', null);
}
setGlobalEnvironments(globalEnvironments) {
globalEnvironments = this.encryptGlobalEnvironmentVariables({ globalEnvironments });
return this.store.set('environments', globalEnvironments);
}
setActiveGlobalEnvironmentUid(uid) {
return this.store.set('activeGlobalEnvironmentUid', uid);
}
addGlobalEnvironment({ uid, name, variables = [] }) {
let globalEnvironments = this.getGlobalEnvironments();
globalEnvironments.push({
uid,
name,
variables
});
this.setGlobalEnvironments(globalEnvironments);
}
saveGlobalEnvironment({ environmentUid: globalEnvironmentUid, variables }) {
let globalEnvironments = this.getGlobalEnvironments();
const environment = globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
globalEnvironments = globalEnvironments.filter(env => env?.uid !== globalEnvironmentUid);
if (environment) {
environment.variables = variables;
}
globalEnvironments.push(environment);
this.setGlobalEnvironments(globalEnvironments);
}
renameGlobalEnvironment({ environmentUid: globalEnvironmentUid, name }) {
let globalEnvironments = this.getGlobalEnvironments();
const environment = globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
globalEnvironments = globalEnvironments.filter(env => env?.uid !== globalEnvironmentUid);
if (environment) {
environment.name = name;
}
globalEnvironments.push(environment);
this.setGlobalEnvironments(globalEnvironments);
}
copyGlobalEnvironment({ uid, name, variables }) {
let globalEnvironments = this.getGlobalEnvironments();
globalEnvironments.push({
uid,
name,
variables
});
this.setGlobalEnvironments(globalEnvironments);
}
selectGlobalEnvironment({ environmentUid: globalEnvironmentUid }) {
let globalEnvironments = this.getGlobalEnvironments();
const environment = globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
if (environment) {
this.setActiveGlobalEnvironmentUid(globalEnvironmentUid);
} else {
this.setActiveGlobalEnvironmentUid(null);
}
}
deleteGlobalEnvironment({ environmentUid }) {
let globalEnvironments = this.getGlobalEnvironments();
let activeGlobalEnvironmentUid = this.getActiveGlobalEnvironmentUid();
globalEnvironments = globalEnvironments.filter(env => env?.uid !== environmentUid);
if (environmentUid == activeGlobalEnvironmentUid) {
this.setActiveGlobalEnvironmentUid(null);
}
this.setGlobalEnvironments(globalEnvironments);
}
}
const globalEnvironmentsStore = new GlobalEnvironmentsStore();
module.exports = {
globalEnvironmentsStore
};

View File

@ -4,13 +4,14 @@ const { interpolate } = require('@usebruno/common');
const variableNameRegex = /^[\w-.]*$/;
class Bru {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables) {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables) {
this.envVariables = envVariables || {};
this.runtimeVariables = runtimeVariables || {};
this.processEnvVars = cloneDeep(processEnvVars || {});
this.collectionVariables = collectionVariables || {};
this.folderVariables = folderVariables || {};
this.requestVariables = requestVariables || {};
this.globalEnvironmentVariables = globalEnvironmentVariables || {};
this.collectionPath = collectionPath;
}
@ -20,6 +21,7 @@ class Bru {
}
const combinedVars = {
...this.globalEnvironmentVariables,
...this.collectionVariables,
...this.envVariables,
...this.folderVariables,
@ -63,6 +65,18 @@ class Bru {
this.envVariables[key] = value;
}
getGlobalEnvVar(key) {
return this._interpolate(this.globalEnvironmentVariables[key]);
}
setGlobalEnvVar(key, value) {
if (!key) {
throw new Error('Creating a env variable without specifying a name is not allowed.');
}
this.globalEnvironmentVariables[key] = value;
}
hasVar(key) {
return Object.hasOwn(this.runtimeVariables, key);
}

View File

@ -2,13 +2,14 @@ const { interpolate } = require('@usebruno/common');
const interpolateString = (
str,
{ envVariables = {}, runtimeVariables = {}, processEnvVars = {}, collectionVariables = {}, folderVariables = {}, requestVariables = {} }
{ envVariables = {}, runtimeVariables = {}, processEnvVars = {}, collectionVariables = {}, folderVariables = {}, requestVariables = {}, globalEnvironmentVariables = {} }
) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
const combinedVars = {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,

View File

@ -192,6 +192,7 @@ const evaluateRhsOperand = (rhsOperand, operator, context, runtime) => {
}
const interpolationContext = {
globalEnvironmentVariables: context.bru.globalEnvironmentVariables,
collectionVariables: context.bru.collectionVariables,
folderVariables: context.bru.folderVariables,
requestVariables: context.bru.requestVariables,
@ -240,6 +241,7 @@ class AssertRuntime {
}
runAssertions(assertions, request, response, envVariables, runtimeVariables, processEnvVars) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
@ -255,7 +257,8 @@ class AssertRuntime {
undefined,
collectionVariables,
folderVariables,
requestVariables
requestVariables,
globalEnvironmentVariables
);
const req = new BrunoRequest(request);
const res = createResponseParser(response);
@ -267,6 +270,7 @@ class AssertRuntime {
};
const context = {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,

View File

@ -47,10 +47,11 @@ class ScriptRuntime {
processEnvVars,
scriptingConfig
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables);
const req = new BrunoRequest(request);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
@ -102,6 +103,7 @@ class ScriptRuntime {
request,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
};
}
@ -149,6 +151,7 @@ class ScriptRuntime {
request,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
};
}
@ -164,10 +167,11 @@ class ScriptRuntime {
processEnvVars,
scriptingConfig
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
@ -216,6 +220,7 @@ class ScriptRuntime {
response,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
};
}
@ -263,6 +268,7 @@ class ScriptRuntime {
response,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
};
}

View File

@ -48,10 +48,11 @@ class TestRuntime {
processEnvVars,
scriptingConfig
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
@ -81,12 +82,12 @@ class TestRuntime {
request,
envVariables,
runtimeVariables,
globalEnvironmentVariables,
results: __brunoTestResults.getResults(),
nextRequestName: bru.nextRequest
};
}
const context = {
test,
bru,
@ -162,6 +163,7 @@ class TestRuntime {
request,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest
};

View File

@ -39,6 +39,18 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'setEnvVar', setEnvVar);
setEnvVar.dispose();
let getGlobalEnvVar = vm.newFunction('getGlobalEnvVar', function (key) {
return marshallToVm(bru.getGlobalEnvVar(vm.dump(key)), vm);
});
vm.setProp(bruObject, 'getGlobalEnvVar', getGlobalEnvVar);
getGlobalEnvVar.dispose();
let setGlobalEnvVar = vm.newFunction('setGlobalEnvVar', function (key, value) {
bru.setGlobalEnvVar(vm.dump(key), vm.dump(value));
});
vm.setProp(bruObject, 'setGlobalEnvVar', setGlobalEnvVar);
setGlobalEnvVar.dispose();
let hasVar = vm.newFunction('hasVar', function (key) {
return marshallToVm(bru.hasVar(vm.dump(key)), vm);
});