diff --git a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js index 7af8b9081..f784cf527 100644 --- a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js +++ b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js @@ -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}; } diff --git a/packages/bruno-app/src/components/Dropdown/index.js b/packages/bruno-app/src/components/Dropdown/index.js index e4f48724c..3deb0e849 100644 --- a/packages/bruno-app/src/components/Dropdown/index.js +++ b/packages/bruno-app/src/components/Dropdown/index.js @@ -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 ( - + { {environments && environments.length ? environments.map((e) => (
{ onSelect(e); diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/StyledWrapper.js new file mode 100644 index 000000000..7504d5c1e --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js new file mode 100644 index 000000000..c40591340 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js @@ -0,0 +1,93 @@ +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/globalEnvironments'; + +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 ( +
+ + { + activeEnvironment ?
{activeEnvironment?.name}
: null + } +
+ ); + }); + + 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 ( + +
+ } placement="bottom-end" transparent={true}> + {globalEnvironments && globalEnvironments.length + ? globalEnvironments.map((e) => ( +
{ + onSelect(e); + dropdownTippyRef.current.hide(); + }} + > + {e.name} +
+ )) + : null} +
{ + dropdownTippyRef.current.hide(); + onSelect(null); + }} + > + + No Environment +
+
+
+ +
+ Configure +
+
+
+ {openSettingsModal && } +
+ ); +}; + +export default EnvironmentSelector; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js new file mode 100644 index 000000000..ce9266f3e --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js @@ -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/globalEnvironments'; +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 ( + + +
e.preventDefault()}> +
+ + + {formik.touched.name && formik.errors.name ? ( +
{formik.errors.name}
+ ) : null} +
+
+
+
+ ); +}; + +export default CopyEnvironment; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js new file mode 100644 index 000000000..e4ee83cb4 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js @@ -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/globalEnvironments'; + +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 ( + + +
e.preventDefault()}> +
+ +
+ +
+ {formik.touched.name && formik.errors.name ? ( +
{formik.errors.name}
+ ) : null} +
+
+
+
+ ); +}; + +export default CreateEnvironment; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js new file mode 100644 index 000000000..48b874214 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js new file mode 100644 index 000000000..e4ddb73f8 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js @@ -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/globalEnvironments'; + +const DeleteEnvironment = ({ onClose, environment }) => { + const dispatch = useDispatch(); + const onConfirm = () => { + dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid })) + .then(() => { + toast.success('Environment deleted successfully'); + onClose(); + }) + .catch(() => toast.error('An error occurred while deleting the environment')); + }; + + return ( + + + + Are you sure you want to delete {environment.name} ? + + + + ); +}; + +export default DeleteEnvironment; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js new file mode 100644 index 000000000..715bf9e75 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js @@ -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( + { + e.stopPropagation(); + e.preventDefault(); + }} + hideFooter={true} + > +
+ +

Hold on..

+
+
You have unsaved changes in this environment.
+ +
+
+ +
+
+
+
, + document.body + ); +}; + +export default ConfirmSwitchEnv; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js new file mode 100644 index 000000000..7eec1394c --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js new file mode 100644 index 000000000..93e7d687e --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -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/globalEnvironments'; + +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) { + return null; + } + + return ( + + ); + }; + + 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 ( + +
+ + + + + + + + + + + + {formik.values.map((variable, index) => ( + + + + + + + + ))} + +
EnabledNameValueSecret
+ + + + + +
+ formik.setFieldValue(`${index}.value`, newValue, true)} + /> +
+
+ + + +
+
+ +
+
+ +
+ + +
+
+ ); +}; +export default EnvironmentVariables; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js new file mode 100644 index 000000000..e4dba0e9c --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js @@ -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 ( +
+ {openEditModal && ( + setOpenEditModal(false)} environment={environment} /> + )} + {openDeleteModal && ( + setOpenDeleteModal(false)} + environment={environment} + /> + )} + {openCopyModal && ( + setOpenCopyModal(false)} environment={environment} /> + )} +
+
+ + {environment.name} +
+
+ setOpenEditModal(true)} /> + setOpenCopyModal(true)} /> + setOpenDeleteModal(true)} /> +
+
+ +
+ +
+
+ ); +}; + +export default EnvironmentDetails; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/StyledWrapper.js new file mode 100644 index 000000000..330ae082c --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js new file mode 100644 index 000000000..130b7e6d8 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js @@ -0,0 +1,136 @@ +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 ManageSecrets from '../ManageSecrets'; +import StyledWrapper from './StyledWrapper'; +import ConfirmSwitchEnv from './ConfirmSwitchEnv'; + +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 ( + + {openCreateModal && setOpenCreateModal(false)} />} + {openManageSecretsModal && setOpenManageSecretsModal(false)} />} + +
+
+ {switchEnvConfirmClose && ( +
+ handleConfirmSwitch(false)} /> +
+ )} +
+ {environments && + environments.length && + environments.map((env) => ( +
handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks + > + {env.name} +
+ ))} +
handleCreateEnvClick()}> + + Create +
+ +
+
handleImportClick()}> + + Import +
+
handleSecretsClick()}> + + Managing Secrets +
+
+
+
+ +
+
+ ); +}; + +export default EnvironmentList; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ManageSecrets/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ManageSecrets/index.js new file mode 100644 index 000000000..ca025003c --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ManageSecrets/index.js @@ -0,0 +1,31 @@ +import React from 'react'; +import Portal from 'components/Portal'; +import Modal from 'components/Modal'; + +const ManageSecrets = ({ onClose }) => { + return ( + + +
+

In any collection, there are secrets that need to be managed.

+

These secrets can be anything such as API keys, passwords, or tokens.

+

Bruno offers two approaches to manage secrets in collections.

+

+ Read more about it in our{' '} + + docs + + . +

+
+
+
+ ); +}; + +export default ManageSecrets; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js new file mode 100644 index 000000000..66bebe0f2 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js @@ -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/globalEnvironments'; + +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 ( + + +
e.preventDefault()}> +
+ + + {formik.touched.name && formik.errors.name ? ( +
{formik.errors.name}
+ ) : null} +
+
+
+
+ ); +}; + +export default RenameEnvironment; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/StyledWrapper.js new file mode 100644 index 000000000..2dfad0cfe --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js new file mode 100644 index 000000000..1e3052120 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js @@ -0,0 +1,69 @@ +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'; + +export const SharedButton = ({ children, className, onClick }) => { + return ( + + ); +}; + +const DefaultTab = ({ setTab }) => { + return ( +
+ + No Global Environments found +
+ setTab('create')}> + Create Global Environment + +
+
+ ); +}; + +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 ( + + + {tab === 'create' ? ( + setTab('default')} /> + ) : ( + <> + )} + + + + ); + } + + return ( + + + + ); +}; + +export default EnvironmentSettings; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js index 26ec31545..e19da78da 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js @@ -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 }) => { {collection?.name}
-
+
@@ -67,6 +68,7 @@ const CollectionToolBar = ({ collection }) => { +
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index f4a04030f..42609d110 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -23,6 +23,7 @@ import { collectionAddEnvFileEvent, openCollectionEvent } from 'providers/ReduxS import toast from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; +import { updateGlobalEnvironments } from 'providers/ReduxStore/slices/globalEnvironments'; const useIpcEvents = () => { const dispatch = useDispatch(); @@ -149,6 +150,10 @@ const useIpcEvents = () => { dispatch(updateCookies(val)); }); + const removeGlobalEnvironmentsUpdatesListener = ipcRenderer.on('main:load-global-environments', (val) => { + dispatch(updateGlobalEnvironments(val)); + }); + return () => { removeCollectionTreeUpdateListener(); removeOpenCollectionListener(); @@ -165,6 +170,7 @@ const useIpcEvents = () => { removePreferencesUpdatesListener(); removeCookieUpdateListener(); removeSystemProxyEnvUpdatesListener(); + removeGlobalEnvironmentsUpdatesListener(); }; }, [isElectron]); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index f8ae75d64..c7641b506 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -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/globalEnvironments'; 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) }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/globalEnvironments.js b/packages/bruno-app/src/providers/ReduxStore/slices/globalEnvironments.js new file mode 100644 index 000000000..35e864ef0 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/globalEnvironments.js @@ -0,0 +1,193 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { generateUidBasedOnHash } from 'utils/common/index'; +import { environmentSchema } from '@usebruno/schema'; + +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 } = 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 }) => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const uid = generateUidBasedOnHash(name); + ipcRenderer + .invoke('renderer:create-global-environment', { name, uid }) + .then( + dispatch(_addGlobalEnvironment({ name, uid })) + ) + .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 = generateUidBasedOnHash(name); + 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 + // variables: variables?.map(v => { + // let { uid, ...rest } = v; + // return rest; + // }) + })) + .then( + dispatch(_saveGlobalEnvironment({ environmentUid, variables })) + ) + .then(resolve) + .catch((error) => { + console.error(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 default globalEnvironmentsSlice.reducer; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index ea8712be5..2b2c7d13b 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -824,6 +824,7 @@ 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); diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index 05f1bad2f..f9ca5a002 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -158,3 +158,9 @@ export const humanizeDate = (dateString) => { day: 'numeric' }); }; + +export const generateUidBasedOnHash = (str) => { + const hash = simpleHash(str); + + return `${hash}`.padEnd(21, '0'); +}; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 6efc531c0..e6ff23e22 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -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 registerCommonIpc = require('./ipc/common'); const lastOpenedCollections = new LastOpenedCollections(); @@ -143,6 +144,7 @@ app.on('ready', async () => { // register all ipc handlers registerNetworkIpc(mainWindow); + registerCommonIpc(mainWindow); registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections); registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections); registerNotificationsIpc(mainWindow, watcher); diff --git a/packages/bruno-electron/src/ipc/common.js b/packages/bruno-electron/src/ipc/common.js new file mode 100644 index 000000000..7d77777a3 --- /dev/null +++ b/packages/bruno-electron/src/ipc/common.js @@ -0,0 +1,50 @@ +require('dotenv').config(); +const { ipcMain } = require('electron'); +const { globalEnvironmentsStore } = require('../store/global-environments'); + +const registerCommonIpc = (mainWindow) => { + + // GLOBAL ENVIRONMENTS + + ipcMain.handle('renderer:create-global-environment', async (event, { uid, name }) => { + try { + globalEnvironmentsStore.addGlobalEnvironment({ uid, name }); + } 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, { uid }) => { + try { + globalEnvironmentsStore.deleteGlobalEnvironment({ uid }); + } 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 = registerCommonIpc; \ No newline at end of file diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js index 0486ead5e..4c9f5af25 100644 --- a/packages/bruno-electron/src/ipc/preferences.js +++ b/packages/bruno-electron/src/ipc/preferences.js @@ -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,16 @@ 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(); + const activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid(); + mainWindow.webContents.send('main:load-global-environments', { globalEnvironments, activeGlobalEnvironmentUid }); + // reload last opened collections const lastOpened = lastOpenedCollections.getAll(); diff --git a/packages/bruno-electron/src/store/global-environments.js b/packages/bruno-electron/src/store/global-environments.js new file mode 100644 index 000000000..c3e5b4f2e --- /dev/null +++ b/packages/bruno-electron/src/store/global-environments.js @@ -0,0 +1,92 @@ +const _ = require('lodash'); +const Store = require('electron-store'); + +class GlobalEnvironmentsStore { + constructor() { + this.store = new Store({ + name: 'global-environments', + clearInvalidConfig: true + }); + } + + getGlobalEnvironments() { + return this.store.get('environments', []); + } + + getActiveGlobalEnvironmentUid() { + return this.store.get('activeGlobalEnvironmentUid', null); + } + + setGlobalEnvironments(environments) { + return this.store.set('environments', environments); + } + + setActiveGlobalEnvironmentUid(uid) { + return this.store.set('activeGlobalEnvironmentUid', uid); + } + + addGlobalEnvironment({ uid, name }) { + 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({ uid }) { + let globalEnvironments = this.getGlobalEnvironments(); + globalEnvironments = globalEnvironments.filter(env => env?.uid !== uid); + this.setGlobalEnvironments(globalEnvironments); + } +} + +const globalEnvironmentsStore = new GlobalEnvironmentsStore(); + +module.exports = { + globalEnvironmentsStore +};