diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js index 4ee77acc5..b90fe1f35 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js @@ -1,11 +1,11 @@ import React from 'react'; import { getTotalRequestCountInCollection } from 'utils/collections/'; -import { IconBox, IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons'; +import { IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons'; import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index'; import { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import ShareCollection from 'components/ShareCollection/index'; -import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app'; +import { addTab } from 'providers/ReduxStore/slices/tabs'; const Info = ({ collection }) => { const dispatch = useDispatch(); @@ -53,7 +53,13 @@ const Info = ({ collection }) => { type="button" className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent" onClick={() => { - dispatch(updateEnvironmentSettingsModalVisibility(true)); + dispatch( + addTab({ + uid: `${collection.uid}-environment-settings`, + collectionUid: collection.uid, + type: 'environment-settings' + }) + ); }} > {collectionEnvironmentCount} collection environment{collectionEnvironmentCount !== 1 ? 's' : ''} @@ -61,7 +67,15 @@ const Info = ({ collection }) => { diff --git a/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js b/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js new file mode 100644 index 000000000..6e5cc8fa9 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { IconAlertTriangle } from '@tabler/icons'; +import Modal from 'components/Modal'; +import Portal from 'components/Portal'; + +const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => { + return ( + + +
+ +

Hold on...

+
+
+ You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings. +
+ +
+
+ +
+
+ + +
+
+
+
+ ); +}; + +export default ConfirmCloseEnvironment; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js index 924fcaba3..53438c459 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js @@ -1,18 +1,16 @@ import React, { useMemo, useState, useRef, forwardRef } from 'react'; import find from 'lodash/find'; import Dropdown from 'components/Dropdown'; -import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons'; +import { IconWorld, IconDatabase, IconCaretDown } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; -import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app'; +import { addTab } from 'providers/ReduxStore/slices/tabs'; import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import toast from 'react-hot-toast'; import EnvironmentListContent from './EnvironmentListContent/index'; -import EnvironmentSettings from '../EnvironmentSettings'; -import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings'; import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment'; import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; -import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment'; +import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment'; import ToolHint from 'components/ToolHint'; import StyledWrapper from './StyledWrapper'; @@ -27,8 +25,6 @@ const EnvironmentSelector = ({ collection }) => { const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments); const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid); - const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen); - const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen); const activeGlobalEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null; @@ -75,12 +71,24 @@ const EnvironmentSelector = ({ collection }) => { }); }; - // Settings handler + // Settings handler - opens environment settings tab const handleSettingsClick = () => { if (activeTab === 'collection') { - dispatch(updateEnvironmentSettingsModalVisibility(true)); + dispatch( + addTab({ + uid: `${collection.uid}-environment-settings`, + collectionUid: collection.uid, + type: 'environment-settings' + }) + ); } else { - dispatch(updateGlobalEnvironmentSettingsModalVisibility(true)); + dispatch( + addTab({ + uid: `${collection.uid}-global-environment-settings`, + collectionUid: collection.uid, + type: 'global-environment-settings' + }) + ); } dropdownTippyRef.current.hide(); }; @@ -105,12 +113,6 @@ const EnvironmentSelector = ({ collection }) => { dropdownTippyRef.current.hide(); }; - // Modal handlers - const handleCloseSettings = () => { - dispatch(updateEnvironmentSettingsModalVisibility(false)); - dispatch(updateGlobalEnvironmentSettingsModalVisibility(false)); - }; - // Calculate dropdown width based on the longest environment name. // To prevent resizing while switching between collection and global environments. const dropdownWidth = useMemo(() => { @@ -217,25 +219,17 @@ const EnvironmentSelector = ({ collection }) => { - {/* Modals - Rendered outside dropdown to avoid conflicts */} - {isGlobalEnvironmentSettingsModalOpen && ( - - )} - - {isEnvironmentSettingsModalOpen && ( - - )} - {showCreateGlobalModal && ( setShowCreateGlobalModal(false)} onEnvironmentCreated={() => { - dispatch(updateGlobalEnvironmentSettingsModalVisibility(true)); + dispatch( + addTab({ + uid: `${collection.uid}-global-environment-settings`, + collectionUid: collection.uid, + type: 'global-environment-settings' + }) + ); }} /> )} @@ -245,7 +239,13 @@ const EnvironmentSelector = ({ collection }) => { type="global" onClose={() => setShowImportGlobalModal(false)} onEnvironmentCreated={() => { - dispatch(updateGlobalEnvironmentSettingsModalVisibility(true)); + dispatch( + addTab({ + uid: `${collection.uid}-global-environment-settings`, + collectionUid: collection.uid, + type: 'global-environment-settings' + }) + ); }} /> )} @@ -255,7 +255,13 @@ const EnvironmentSelector = ({ collection }) => { collection={collection} onClose={() => setShowCreateCollectionModal(false)} onEnvironmentCreated={() => { - dispatch(updateEnvironmentSettingsModalVisibility(true)); + dispatch( + addTab({ + uid: `${collection.uid}-environment-settings`, + collectionUid: collection.uid, + type: 'environment-settings' + }) + ); }} /> )} @@ -266,7 +272,13 @@ const EnvironmentSelector = ({ collection }) => { collection={collection} onClose={() => setShowImportCollectionModal(false)} onEnvironmentCreated={() => { - dispatch(updateEnvironmentSettingsModalVisibility(true)); + dispatch( + addTab({ + uid: `${collection.uid}-environment-settings`, + collectionUid: collection.uid, + type: 'environment-settings' + }) + ); }} /> )} diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js deleted file mode 100644 index 6a587b4fc..000000000 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { IconAlertTriangle } from '@tabler/icons'; -import Modal from 'components/Modal'; -import { createPortal } from 'react-dom'; - -const ConfirmSwitchEnv = ({ onCancel }) => { - return createPortal( - { - 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/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js index 0dc900d13..ed2257442 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js @@ -1,67 +1,192 @@ import styled from 'styled-components'; const Wrapper = styled.div` + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + .table-container { + overflow-y: auto; + border-radius: 8px; + border: ${(props) => props.theme.workspace.environments.indentBorder}; + } + table { width: 100%; border-collapse: collapse; - font-weight: 500; table-layout: fixed; + font-size: 12px; - thead, td { - border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}; - padding: 4px 10px; vertical-align: middle; + padding: 2px 10px; - &:nth-child(1), + &:nth-child(1) { + width: 25px; + border-right: none; + } &:nth-child(4) { - width: 70px; + width: 80px; } &:nth-child(5) { - width: 40px; + width: 60px; } &:nth-child(2) { - width: 25%; + width: 30%; } } thead { - color: ${(props) => props.theme.table.thead.color}; + color: ${(props) => props.theme.colors.text}; + background: ${(props) => props.theme.sidebar.bg}; font-size: ${(props) => props.theme.font.size.base}; user-select: none; + + td { + padding: 8px 10px; + border-bottom: ${(props) => props.theme.workspace.environments.indentBorder}; + border-right: ${(props) => props.theme.workspace.environments.indentBorder}; + + &:last-child { + border-right: none; + } + } } - thead td { - padding: 6px 10px; + + tbody { + tr { + transition: background 0.1s ease; + + &:last-child td { + border-bottom: none; + } + + td { + border-bottom: ${(props) => props.theme.workspace.environments.indentBorder}; + border-right: ${(props) => props.theme.workspace.environments.indentBorder}; + + &:last-child { + border-right: none; + } + } + } } } .btn-add-param { - font-size: ${(props) => props.theme.font.size.base}; + font-size: 12px; + color: ${(props) => props.theme.textLink}; + font-weight: 500; + padding: 7px 14px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 6px; + border: ${(props) => props.theme.sidebar.collection.item.indentBorder}; + background: transparent; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.listItem.hoverBg}; + border-color: ${(props) => props.theme.textLink}; + } } .tooltip-mod { - font-size: ${(props) => props.theme.font.size.xs} !important; - width: 150px !important; + font-size: 11px !important; + max-width: 200px !important; } input[type='text'] { width: 100%; - border: solid 1px transparent; + border: 1px solid transparent; outline: none !important; background-color: transparent; + color: ${(props) => props.theme.text}; + padding: 0; + font-size: 12px; + border-radius: 4px; + transition: all 0.15s ease; &:focus { outline: none !important; - border: solid 1px transparent; } } input[type='checkbox'] { cursor: pointer; + width: 14px; + height: 14px; + accent-color: ${(props) => props.theme.workspace.accent}; vertical-align: middle; margin: 0; } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease, background 0.15s ease; + } + + .button-container { + padding: 12px 2px; + background: ${(props) => props.theme.bg}; + flex-shrink: 0; + display: flex; + gap: 8px; + } + + .submit { + padding: 7px 16px; + font-size: 12px; + font-weight: 500; + border-radius: 6px; + border: none; + background: ${(props) => props.theme.workspace.accent}; + color: ${(props) => props.theme.bg}; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.9; + } + } + + .reset { + background: transparent; + padding: 6px 16px; + border: 1px solid ${(props) => props.theme.workspace.accent}; + color: ${(props) => props.theme.workspace.accent}; + &:hover { + opacity: 0.9; + } + } + + .discard { + padding: 7px 16px; + font-size: 12px; + font-weight: 500; + border-radius: 6px; + background: transparent; + color: ${(props) => props.theme.text}; + border: ${(props) => props.theme.sidebar.collection.item.indentBorder}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } `; export default Wrapper; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index d9c286317..0e10fe9b3 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -1,34 +1,43 @@ -import React, { useRef, useEffect, useMemo } from 'react'; +import React, { useCallback, useRef, useMemo, useEffect } from 'react'; import cloneDeep from 'lodash/cloneDeep'; import { get } from 'lodash'; -import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons'; +import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons'; import { useTheme } from 'providers/Theme'; import { useDispatch, useSelector } from 'react-redux'; -import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import MultiLineEditor from 'components/MultiLineEditor/index'; import StyledWrapper from './StyledWrapper'; import { uuid } from 'utils/common'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import { variableNameRegex } from 'utils/common/regex'; -import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import toast from 'react-hot-toast'; +import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; import { Tooltip } from 'react-tooltip'; -import SensitiveFieldWarning from 'components/SensitiveFieldWarning'; import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections'; +import SensitiveFieldWarning from 'components/SensitiveFieldWarning'; import { sensitiveFields } from './constants'; -const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => { +const EnvironmentVariables = ({ environment, setIsModified, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const addButtonRef = useRef(null); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); - let _collection = cloneDeep(collection); + const environmentsDraft = collection?.environmentsDraft; + const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid; + + // Track environment changes for draft restoration + const prevEnvUidRef = React.useRef(null); + const mountedRef = React.useRef(false); + + let _collection = collection ? cloneDeep(collection) : {}; const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - _collection.globalEnvironmentVariables = globalEnvironmentVariables; + if (_collection) { + _collection.globalEnvironmentVariables = globalEnvironmentVariables; + } + // Check for non-secret variables used in sensitive fields const nonSecretSensitiveVarUsageMap = useMemo(() => { const result = {}; if (!collection || !environment?.variables) { @@ -44,7 +53,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original const value = get(obj, fieldPath); if (typeof value === 'string') { varNames.forEach((varName) => { - if (new RegExp(`\{\{\s*${varName}\s*\}\}`).test(value)) { + if (new RegExp(`\\{\\{\\s*${varName}\\s*\\}\\}`).test(value)) { result[varName] = true; } }); @@ -73,51 +82,147 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original return result; }, [collection, environment]); + const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name]; + + // Initial values based only on saved environment variables (not draft) + // Draft restoration happens in a separate effect to avoid infinite loops + const initialValues = React.useMemo(() => { + const vars = environment.variables || []; + return [ + ...vars, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + }, [environment.uid, environment.variables]); + const formik = useFormik({ enableReinitialize: true, - initialValues: environment.variables || [], + initialValues: initialValues, validationSchema: Yup.array().of( Yup.object({ enabled: Yup.boolean(), name: Yup.string() - .required('Name cannot be empty') - .matches( - variableNameRegex, - 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.' - ) - .trim(), + .when('$isLastRow', { + is: true, + then: (schema) => schema.optional(), + otherwise: (schema) => + schema + .required('Name cannot be empty') + .matches( + variableNameRegex, + 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.' + ) + .trim() + }), secret: Yup.boolean(), type: Yup.string(), uid: Yup.string(), - value: Yup.string().trim().nullable() + value: Yup.mixed().nullable() }) ), - onSubmit: (values) => { - if (!formik.dirty) { - toast.error('Nothing to save'); - return; - } + validate: (values) => { + const errors = {}; + values.forEach((variable, index) => { + const isLastRow = index === values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; - dispatch(saveEnvironment(cloneDeep(values), environment.uid, collection.uid)) - .then(() => { - toast.success('Changes saved successfully'); - formik.resetForm({ values }); - setIsModified(false); - }) - .catch(() => toast.error('An error occurred while saving the changes')); - } + if (isLastRow && isEmptyRow) { + return; + } + + if (!variable.name || variable.name.trim() === '') { + if (!errors[index]) errors[index] = {}; + errors[index].name = 'Name cannot be empty'; + } else if (!variableNameRegex.test(variable.name)) { + if (!errors[index]) errors[index] = {}; + errors[index].name + = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'; + } + }); + return Object.keys(errors).length > 0 ? errors : {}; + }, + onSubmit: () => {} }); - const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name]; + // Restore draft values on mount or environment switch + useEffect(() => { + const isMount = !mountedRef.current; + const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid; - // Effect to track modifications. - React.useEffect(() => { - setIsModified(formik.dirty); - }, [formik.dirty]); + prevEnvUidRef.current = environment.uid; + mountedRef.current = true; - const ErrorMessage = ({ name }) => { + if ((isMount || envChanged) && hasDraftForThisEnv && environmentsDraft?.variables) { + formik.setValues([ + ...environmentsDraft.variables, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]); + } + }, [environment.uid, hasDraftForThisEnv, environmentsDraft?.variables]); + + const savedValuesJson = useMemo(() => { + return JSON.stringify(environment.variables || []); + }, [environment.variables]); + + useEffect(() => { + const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const currentValuesJson = JSON.stringify(currentValues); + const hasActualChanges = currentValuesJson !== savedValuesJson; + setIsModified(hasActualChanges); + }, [formik.values, savedValuesJson, setIsModified]); + + useEffect(() => { + const timeoutId = setTimeout(() => { + const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const currentValuesJson = JSON.stringify(currentValues); + const hasActualChanges = currentValuesJson !== savedValuesJson; + + // Get existing draft for comparison + const existingDraftVariables = hasDraftForThisEnv ? environmentsDraft?.variables : null; + const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null; + + if (hasActualChanges) { + // Only dispatch if draft values are actually different + if (currentValuesJson !== existingDraftJson) { + dispatch(setEnvironmentsDraft({ + collectionUid: collection.uid, + environmentUid: environment.uid, + variables: currentValues + })); + } + } else if (hasDraftForThisEnv) { + dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [formik.values, savedValuesJson, environment.uid, collection.uid, dispatch, hasDraftForThisEnv, environmentsDraft?.variables]); + + const ErrorMessage = ({ name, index }) => { const meta = formik.getFieldMeta(name); - const id = uuid(); + const id = `error-${name}-${index}`; + + const isLastRow = index === formik.values.length - 1; + const variable = formik.values[index]; + const isEmptyRow = !variable?.name || variable.name.trim() === ''; + + if (isLastRow && isEmptyRow) { + return null; + } + if (!meta.error || !meta.touched) { return null; } @@ -129,54 +234,161 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original ); }; - const addVariable = () => { - const newVariable = { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - }; - formik.setFieldValue(formik.values.length, newVariable, false); - }; + const handleRemoveVar = useCallback((id) => { + const currentValues = formik.values; - const onActivate = () => { - dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid)) - .then(() => { - if (environment) { - toast.success(`Environment changed to ${environment.name}`); - onClose(); - } else { - toast.success(`No Environments are active now`); - } - }) - .catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment')); - }; - - const handleRemoveVar = (id) => { - formik.setValues(formik.values.filter((variable) => variable.uid !== id)); - }; - - useEffect(() => { - if (formik.dirty) { - // Smooth scrolling to the changed parameter is temporarily disabled - // due to UX issues when editing the first row in a long list of environment variables. - // addButtonRef.current?.scrollIntoView({ behavior: 'smooth' }); + if (!currentValues || currentValues.length === 0) { + return; } - }, [formik.values, formik.dirty]); + + const lastRow = currentValues[currentValues.length - 1]; + const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === ''); + + if (isLastEmptyRow) { + return; + } + + const filteredValues = currentValues.filter((variable) => variable.uid !== id); + + const hasEmptyLastRow + = filteredValues.length > 0 + && (!filteredValues[filteredValues.length - 1].name + || filteredValues[filteredValues.length - 1].name.trim() === ''); + + const newValues = hasEmptyLastRow + ? filteredValues + : [ + ...filteredValues, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + + formik.setValues(newValues); + }, [formik.values]); + + const handleNameChange = (index, e) => { + formik.handleChange(e); + const isLastRow = index === formik.values.length - 1; + + if (isLastRow) { + const newVariable = { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + }; + setTimeout(() => { + formik.setFieldValue(formik.values.length, newVariable, false); + }, 0); + } + }; + + const handleNameBlur = (index) => { + formik.setFieldTouched(`${index}.name`, true, true); + }; + + const handleNameKeyDown = (index, e) => { + if (e.key === 'Enter') { + e.preventDefault(); + formik.setFieldTouched(`${index}.name`, true, true); + } + }; + + const handleSave = () => { + const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const savedValues = environment.variables || []; + + const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues); + if (!hasChanges) { + toast.error('No changes to save'); + return; + } + + const hasValidationErrors = variablesToSave.some((variable) => { + if (!variable.name || variable.name.trim() === '') { + return true; + } + if (!variableNameRegex.test(variable.name)) { + return true; + } + return false; + }); + + if (hasValidationErrors) { + toast.error('Please fix validation errors before saving'); + return; + } + + dispatch(saveEnvironment(cloneDeep(variablesToSave), environment.uid, collection.uid)) + .then(() => { + toast.success('Changes saved successfully'); + const newValues = [ + ...variablesToSave, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + formik.resetForm({ values: newValues }); + setIsModified(false); + }) + .catch((error) => { + console.error(error); + toast.error('An error occurred while saving the changes'); + }); + }; const handleReset = () => { - formik.resetForm({ originalEnvironmentVariables }); + const originalVars = environment.variables || []; + const resetValues = [ + ...originalVars, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + formik.resetForm({ values: resetValues }); + setIsModified(false); }; + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + useEffect(() => { + const handleSaveEvent = () => { + handleSaveRef.current(); + }; + + window.addEventListener('environment-save', handleSaveEvent); + + return () => { + window.removeEventListener('environment-save', handleSaveEvent); + }; + }, []); + return ( - -
- + +
+
- + @@ -184,99 +396,112 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original - {formik.values.map((variable, index) => ( - - - - - - - - ))} + {formik.values.map((variable, index) => { + const isLastRow = index === formik.values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + const isLastEmptyRow = isLastRow && isEmptyRow; + + return ( + + + + + + + + ); + })}
Enabled Name Value Secret
- - -
- - -
-
-
- formik.setFieldValue(`${index}.value`, newValue, true)} - enableBrunoVarInfo={false} - /> -
- {!variable.secret && hasSensitiveUsage(variable.name) && ( - - )} -
- - - -
+ {!isLastEmptyRow && ( + + )} + +
+ handleNameChange(index, e)} + onBlur={() => handleNameBlur(index)} + onKeyDown={(e) => handleNameKeyDown(index, e)} + /> + +
+
+
+ formik.setFieldValue(`${index}.value`, newValue, true)} + onSave={handleSave} + /> +
+ {typeof variable.value !== 'string' && ( + + + + + )} + {!variable.secret && hasSensitiveUsage(variable.name) && ( + + )} +
+ {!isLastEmptyRow && ( + + )} + + {!isLastEmptyRow && ( + + )} +
-
- -
-
- - - +
+
+ + +
); }; + export default EnvironmentVariables; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/StyledWrapper.js new file mode 100644 index 000000000..388236bce --- /dev/null +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/StyledWrapper.js @@ -0,0 +1,134 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: ${(props) => props.theme.bg}; + + .header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 8px 20px; + flex-shrink: 0; + + .title { + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin: 0; + } + + .title-container { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + + &.renaming { + .title-input { + flex: 1; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + outline: none; + color: ${(props) => props.theme.text}; + font-size: 15px; + font-weight: 600; + padding: 4px 8px; + border-radius: 5px; + } + + .inline-actions { + display: flex; + gap: 2px; + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + + &.save { + color: ${(props) => props.theme.textLink}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + + &.cancel { + color: ${(props) => props.theme.colors.text.muted}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + } + } + } + + .title-error { + position: absolute; + top: 100%; + left: 20px; + margin-top: 4px; + padding: 4px 8px; + font-size: 11px; + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => props.theme.bg}; + border: 1px solid ${(props) => props.theme.colors.text.danger}; + border-radius: 4px; + white-space: nowrap; + } + + .actions { + display: flex; + gap: 2px; + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + border-radius: 5px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &:last-child:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + } + } + } + + .content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 0 20px 20px 20px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js index d1033f511..eaf092f14 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js @@ -1,59 +1,183 @@ -import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons'; -import { useState } from 'react'; -import CopyEnvironment from '../../CopyEnvironment'; -import DeleteEnvironment from '../../DeleteEnvironment'; -import RenameEnvironment from '../../RenameEnvironment'; +import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons'; +import { useState, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { validateName, validateNameError } from 'utils/common/regex'; +import toast from 'react-hot-toast'; +import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment'; +import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment'; import EnvironmentVariables from './EnvironmentVariables'; -import ToolHint from 'components/ToolHint/index'; +import StyledWrapper from './StyledWrapper'; + +const EnvironmentDetails = ({ environment, setIsModified, collection }) => { + const dispatch = useDispatch(); + const environments = collection?.environments || []; -const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => { - const [openEditModal, setOpenEditModal] = useState(false); const [openDeleteModal, setOpenDeleteModal] = useState(false); const [openCopyModal, setOpenCopyModal] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [newName, setNewName] = useState(''); + const [nameError, setNameError] = useState(''); + const inputRef = useRef(null); + + const validateEnvironmentName = (name) => { + if (!name || name.trim() === '') { + return 'Name is required'; + } + + if (name.length < 1) { + return 'Must be at least 1 character'; + } + + if (name.length > 255) { + return 'Must be 255 characters or less'; + } + + if (!validateName(name)) { + return validateNameError(name); + } + + const trimmedName = name.toLowerCase().trim(); + const isDuplicate = (environments || []).some( + (env) => env?.uid !== environment.uid && env?.name?.toLowerCase().trim() === trimmedName + ); + if (isDuplicate) { + return 'Environment already exists'; + } + + return null; + }; + + const handleRenameClick = () => { + setIsRenaming(true); + setNewName(environment.name); + setNameError(''); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 50); + }; + + const handleSaveRename = () => { + const error = validateEnvironmentName(newName); + if (error) { + setNameError(error); + return; + } + + dispatch(renameEnvironment(newName, environment.uid, collection.uid)) + .then(() => { + toast.success('Environment renamed!'); + setIsRenaming(false); + setNewName(''); + setNameError(''); + }) + .catch(() => { + toast.error('An error occurred while renaming the environment'); + }); + }; + + const handleCancelRename = () => { + setIsRenaming(false); + setNewName(''); + setNameError(''); + }; + + const handleNameChange = (e) => { + setNewName(e.target.value); + if (nameError) { + setNameError(''); + } + }; + + const handleNameBlur = () => { + if (newName.trim() === '') { + handleCancelRename(); + } else { + const error = validateEnvironmentName(newName); + if (error) { + setNameError(error); + } + } + }; + + const handleNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelRename(); + } + }; return ( -
- {openEditModal && ( - setOpenEditModal(false)} environment={environment} collection={collection} /> - )} + {openDeleteModal && ( - setOpenDeleteModal(false)} - environment={environment} - collection={collection} - /> + setOpenDeleteModal(false)} environment={environment} collection={collection} /> )} {openCopyModal && ( setOpenCopyModal(false)} environment={environment} collection={collection} /> )} -
-
- - {environment.name} + +
+
+ {isRenaming ? ( + <> + +
+ + +
+ + ) : ( +

{environment.name}

+ )}
-
- - setOpenEditModal(true)} /> - - - setOpenCopyModal(true)} /> - - - setOpenDeleteModal(true)} - data-testid="delete-environment-button" - /> - + {nameError && isRenaming &&
{nameError}
} +
+ + +
-
- +
+
-
+ ); }; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js index dd9761532..750863fb3 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -1,61 +1,281 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - margin-inline: -1rem; - margin-block: -1.5rem; + display: flex; + height: 100%; + overflow: hidden; + background-color: ${(props) => props.theme.bg}; + position: relative; - background-color: ${(props) => props.theme.collection.environment.settings.bg}; - - .environments-sidebar { - background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg}; - border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight}; - min-height: 400px; + .environments-container { + display: flex; height: 100%; - max-height: 85vh; + width: 100%; + overflow: hidden; + } + + .confirm-switch-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + background: ${(props) => props.theme.bg}; + padding: 12px; + border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; + } + + /* Left Sidebar */ + .sidebar { + width: 240px; + min-width: 240px; + border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; + display: flex; + flex-direction: column; + } + + .sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 16px 12px 16px; + + .title { + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin: 0; + } + + .btn-action { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + } + + .search-container { + position: relative; + padding: 0 12px 12px 12px; + + .search-icon { + position: absolute; + left: 20px; + top: 50%; + transform: translateY(-100%); + color: ${(props) => props.theme.colors.text.muted}; + pointer-events: none; + } + + .search-input { + width: 100%; + padding: 6px 8px 6px 28px; + font-size: 12px; + background: transparent; + border: ${(props) => props.theme.sidebar.collection.item.indentBorder}; + border-radius: 5px; + color: ${(props) => props.theme.text}; + transition: all 0.15s ease; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + + &:focus { + outline: none; + } + } + } + + .environments-list { + flex: 1; overflow-y: auto; + padding: 0 8px; } .environment-item { - min-width: 150px; - display: block; position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + margin-bottom: 1px; + font-size: 13px; + color: ${(props) => props.theme.text}; cursor: pointer; - padding: 8px 10px; - border-left: solid 2px transparent; - text-decoration: none; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + border-radius: 5px; + transition: background 0.15s ease; + + .environment-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .environment-actions { + display: flex; + align-items: center; + opacity: 0; + transition: opacity 0.15s ease; + + .activate-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: transparent; + cursor: pointer; + color: ${(props) => props.theme.text.muted}; + border-radius: 3px; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.workspace.button.bg}; + color: ${(props) => props.theme.colors.text.green}; + } + } + + .activated-checkmark { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + color: ${(props) => props.theme.colors.text.green}; + opacity: 1; + } + } + + &:hover .environment-actions { + opacity: 1; + } + + &.activated .environment-actions { + opacity: 1; + } &:hover { - text-decoration: none; - background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg}; + background: ${(props) => props.theme.workspace.button.bg}; + } + + &.active { + background: ${(props) => props.theme.workspace.environments.activeBg}; + color: ${(props) => props.theme.text}; + } + + &.renaming, + &.creating { + cursor: default; + padding: 4px 4px 4px 8px; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + + &:hover { + background: ${(props) => props.theme.workspace.button.bg}; + } + } + + .rename-container { + display: flex; + align-items: center; + flex: 1; + + .environment-name-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: ${(props) => props.theme.text}; + font-size: 13px; + padding: 2px 4px; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .inline-actions { + display: flex; + gap: 2px; + margin-left: 4px; + } + } + + &.creating { + .environment-name-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: ${(props) => props.theme.text}; + font-size: 13px; + padding: 2px 4px; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .inline-actions { + display: flex; + gap: 2px; + margin-left: 4px; + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + + &.save { + color: ${(props) => props.theme.textLink}; + + &:hover { + background: ${(props) => props.theme.listItem.hoverBg}; + } + } + + &.cancel { + color: ${(props) => props.theme.colors.text.muted}; + + &:hover { + background: ${(props) => props.theme.listItem.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + } } } - - .active { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important; - border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border}; - &:hover { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important; - } - } - - .btn-create-environment, - .btn-import-environment { - padding: 8px 10px; - cursor: pointer; - border-bottom: none; - color: ${(props) => props.theme.textLink}; - - span:hover { - text-decoration: underline; - } - } - - .btn-import-environment { - color: ${(props) => props.theme.colors.text.muted}; + + .env-error { + padding: 4px 12px; + margin-top: 4px; + font-size: 11px; + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => `${props.theme.colors.text.danger}15`}; + border-radius: 4px; } `; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js index 196d68bde..e7d154022 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js @@ -1,21 +1,39 @@ -import React, { useEffect, useState } from 'react'; -import { findEnvironmentInCollection } from 'utils/collections'; +import React, { useEffect, useState, useRef } from 'react'; import usePrevious from 'hooks/usePrevious'; import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from '../CreateEnvironment'; -import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons'; -import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; -import ManageSecrets from '../ManageSecrets'; +import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment'; +import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; -import ConfirmSwitchEnv from './ConfirmSwitchEnv'; -import ToolHint from 'components/ToolHint'; +import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv'; +import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; import { isEqual } from 'lodash'; +import { useDispatch } from 'react-redux'; +import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { validateName, validateNameError } from 'utils/common/regex'; +import toast from 'react-hot-toast'; + +const EnvironmentList = ({ + environments, + activeEnvironmentUid, + selectedEnvironment, + setSelectedEnvironment, + isModified, + setIsModified, + collection, + setShowExportModal +}) => { + const dispatch = useDispatch(); -const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose, setShowExportModal }) => { - const { environments } = collection; const [openCreateModal, setOpenCreateModal] = useState(false); const [openImportModal, setOpenImportModal] = useState(false); - const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false); + const [searchText, setSearchText] = useState(''); + const [isCreatingInline, setIsCreatingInline] = useState(false); + const [renamingEnvUid, setRenamingEnvUid] = useState(null); + const [newEnvName, setNewEnvName] = useState(''); + const [envNameError, setEnvNameError] = useState(''); + const inputRef = useRef(null); + const renameContainerRef = useRef(null); + const createContainerRef = useRef(null); const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false); const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]); @@ -24,23 +42,36 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti const prevEnvUids = usePrevious(envUids); useEffect(() => { - if (selectedEnvironment) { - const _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid); - const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment); - if (hasSelectedEnvironmentChanged) { - setSelectedEnvironment(_selectedEnvironment); - } - setOriginalEnvironmentVariables(selectedEnvironment.variables); + if (!environments?.length) { + setSelectedEnvironment(null); + setOriginalEnvironmentVariables([]); return; } - const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid); - if (environment) { - setSelectedEnvironment(environment); - } else { - setSelectedEnvironment(environments && environments.length ? environments[0] : null); + if (selectedEnvironment) { + let _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid); + + if (!_selectedEnvironment) { + _selectedEnvironment = environments?.find((env) => env?.name === selectedEnvironment?.name); + } + + if (!_selectedEnvironment) { + _selectedEnvironment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0]; + } + + const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment); + if (hasSelectedEnvironmentChanged || selectedEnvironment.uid !== _selectedEnvironment?.uid) { + setSelectedEnvironment(_selectedEnvironment); + } + setOriginalEnvironmentVariables(_selectedEnvironment?.variables || []); + return; } - }, [collection, environments, selectedEnvironment]); + + const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0]; + + setSelectedEnvironment(environment); + setOriginalEnvironmentVariables(environment?.variables || []); + }, [environments, activeEnvironmentUid, selectedEnvironment]); useEffect(() => { if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) { @@ -55,6 +86,36 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti } }, [envUids, environments, prevEnvUids]); + useEffect(() => { + if (!renamingEnvUid) return; + + const handleClickOutside = (event) => { + if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) { + handleCancelRename(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [renamingEnvUid]); + + useEffect(() => { + if (!isCreatingInline) return; + + const handleClickOutside = (event) => { + if (createContainerRef.current && !createContainerRef.current.contains(event.target)) { + handleCancelCreate(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isCreatingInline]); + const handleEnvironmentClick = (env) => { if (!isModified) { setSelectedEnvironment(env); @@ -63,18 +124,141 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti } }; + const handleEnvironmentDoubleClick = (env) => { + setRenamingEnvUid(env.uid); + setNewEnvName(env.name); + setEnvNameError(''); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 50); + }; + + const handleActivateEnvironment = (e, env) => { + e.stopPropagation(); + dispatch(selectEnvironment(env.uid, collection.uid)) + .then(() => { + toast.success(`Environment "${env.name}" activated`); + }) + .catch(() => { + toast.error('Failed to activate environment'); + }); + }; + if (!selectedEnvironment) { return null; } + const validateEnvironmentName = (name, excludeUid = null) => { + if (!name || name.trim() === '') { + return 'Name is required'; + } + + if (!validateName(name)) { + return validateNameError(name); + } + + const trimmedName = name.toLowerCase().trim(); + const isDuplicate = environments.some( + (env) => env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName + ); + if (isDuplicate) { + return 'Environment already exists'; + } + + return null; + }; + const handleCreateEnvClick = () => { if (!isModified) { - setOpenCreateModal(true); + setIsCreatingInline(true); + setNewEnvName(''); + setEnvNameError(''); + setTimeout(() => { + inputRef.current?.focus(); + }, 50); } else { setSwitchEnvConfirmClose(true); } }; + const handleCancelCreate = () => { + setIsCreatingInline(false); + setNewEnvName(''); + setEnvNameError(''); + }; + + const handleSaveNewEnv = () => { + const error = validateEnvironmentName(newEnvName); + if (error) { + setEnvNameError(error); + return; + } + + dispatch(addEnvironment(newEnvName, collection.uid)) + .then(() => { + toast.success('Environment created!'); + setIsCreatingInline(false); + setNewEnvName(''); + setEnvNameError(''); + }) + .catch(() => { + toast.error('An error occurred while creating the environment'); + }); + }; + + const handleEnvNameChange = (e) => { + const value = e.target.value; + setNewEnvName(value); + + if (envNameError) { + setEnvNameError(''); + } + }; + + const handleEnvNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (renamingEnvUid) { + handleSaveRename(); + } else { + handleSaveNewEnv(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + if (renamingEnvUid) { + handleCancelRename(); + } else { + handleCancelCreate(); + } + } + }; + + const handleSaveRename = () => { + const error = validateEnvironmentName(newEnvName, renamingEnvUid); + if (error) { + setEnvNameError(error); + return; + } + + dispatch(renameEnvironment(newEnvName, renamingEnvUid, collection.uid)) + .then(() => { + toast.success('Environment renamed!'); + setRenamingEnvUid(null); + setNewEnvName(''); + setEnvNameError(''); + }) + .catch(() => { + toast.error('An error occurred while renaming the environment'); + }); + }; + + const handleCancelRename = () => { + setRenamingEnvUid(null); + setNewEnvName(''); + setEnvNameError(''); + }; + const handleImportClick = () => { if (!isModified) { setOpenImportModal(true); @@ -83,8 +267,10 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti } }; - const handleSecretsClick = () => { - setOpenManageSecretsModal(true); + const handleExportClick = () => { + if (setShowExportModal) { + setShowExportModal(true); + } }; const handleConfirmSwitch = (saveChanges) => { @@ -93,59 +279,160 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti } }; + const filteredEnvironments + = environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || []; + return ( {openCreateModal && setOpenCreateModal(false)} />} - {openImportModal && setOpenImportModal(false)} />} - {openManageSecretsModal && setOpenManageSecretsModal(false)} />} + {openImportModal && ( + setOpenImportModal(false)} /> + )} -
-
- {switchEnvConfirmClose && ( -
- handleConfirmSwitch(false)} /> -
- )} -
- {environments - && environments.length - && environments.map((env) => ( - -
handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks - > - {env.name} -
-
- ))} -
handleCreateEnvClick()}> - + Create -
+
+ {switchEnvConfirmClose && ( +
+ handleConfirmSwitch(false)} /> +
+ )} -
-
handleImportClick()}> - - Import -
-
setShowExportModal(true)}> - - Export -
-
handleSecretsClick()}> - - Managing Secrets -
+
+
+

Environments

+
+ + +
+ +
+ + setSearchText(e.target.value)} + className="search-input" + /> +
+ +
+ {filteredEnvironments.map((env) => ( +
renamingEnvUid !== env.uid && handleEnvironmentClick(env)} + onDoubleClick={() => handleEnvironmentDoubleClick(env)} + > + {renamingEnvUid === env.uid ? ( +
+ +
+ + +
+
+ ) : ( + <> + {env.name} +
+ {activeEnvironmentUid === env.uid ? ( +
+ +
+ ) : ( + + )} +
+ + )} +
+ ))} + + {isCreatingInline && ( +
+ +
+ + +
+
+ )} + + {envNameError && (isCreatingInline || renamingEnvUid) &&
{envNameError}
} +
+
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js deleted file mode 100644 index de50ad92b..000000000 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js +++ /dev/null @@ -1,31 +0,0 @@ -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 three approaches to manage secrets in collections.

-

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

-
-
-
- ); -}; - -export default ManageSecrets; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js deleted file mode 100644 index dc10c8293..000000000 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import Portal from 'components/Portal/index'; -import Modal from 'components/Modal/index'; -import toast from 'react-hot-toast'; -import { useFormik } from 'formik'; -import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions'; -import * as Yup from 'yup'; -import { useDispatch } from 'react-redux'; -import { validateName, validateNameError } from 'utils/common/regex'; - -const RenameEnvironment = ({ onClose, environment, collection }) => { - const dispatch = useDispatch(); - const inputRef = useRef(); - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - name: environment.name - }, - validationSchema: Yup.object({ - name: Yup.string() - .min(1, 'must be at least 1 character') - .max(255, 'Must be 255 characters or less') - .test('is-valid-filename', function (value) { - const isValid = validateName(value); - return isValid ? true : this.createError({ message: validateNameError(value) }); - }) - .required('name is required') - }), - onSubmit: (values) => { - if (values.name === environment.name) { - return; - } - dispatch(renameEnvironment(values.name, environment.uid, collection.uid)) - .then(() => { - toast.success('Environment renamed successfully'); - onClose(); - }) - .catch(() => toast.error('An error occurred while renaming the environment')); - } - }); - - useEffect(() => { - if (inputRef && inputRef.current) { - inputRef.current.focus(); - } - }, [inputRef]); - - const onSubmit = () => { - formik.handleSubmit(); - }; - - return ( - - -
e.preventDefault()}> -
- - - {formik.touched.name && formik.errors.name ? ( -
{formik.errors.name}
- ) : null} -
-
-
-
- ); -}; - -export default RenameEnvironment; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/StyledWrapper.js index 2dfad0cfe..4ddcc5586 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/StyledWrapper.js @@ -1,11 +1,51 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - button.btn-create-environment { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: ${(props) => props.theme.bg}; + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + opacity: 0.3; + margin-bottom: 8px; + } + + .title { + font-size: 13px; + font-weight: 500; + margin-bottom: 12px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .actions { + display: flex; + gap: 8px; + } + } + + .shared-button { + padding: 5px 10px; + font-size: 12px; + border-radius: 5px; + border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; + background: ${(props) => props.theme.sidebar.bg}; + color: ${(props) => props.theme.text}; + cursor: pointer; + transition: all 0.1s ease; + &:hover { - span { - text-decoration: underline; - } + background: ${(props) => props.theme.listItem.hoverBg}; + border-color: ${(props) => props.theme.textLink}; } } `; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js index 8690f7053..2d3492087 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js @@ -1,87 +1,64 @@ -import Modal from 'components/Modal/index'; import React, { useState } from 'react'; -import CreateEnvironment from './CreateEnvironment'; +import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment'; import EnvironmentList from './EnvironmentList'; import StyledWrapper from './StyledWrapper'; -import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; import { IconFileAlert } from '@tabler/icons'; +import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal'; -export const SharedButton = ({ children, className, onClick }) => { - return ( - - ); -}; - -const DefaultTab = ({ setTab }) => { - return ( -
- - No environments found - - Get started by using the following buttons : - -
- setTab('create')}> - Create Environment - - - Or - - setTab('import')}> - Import Environment - -
+const DefaultTab = ({ setTab }) => ( +
+ +
No Environments
+
+ +
- ); -}; +
+); -const EnvironmentSettings = ({ collection, onClose }) => { +const EnvironmentSettings = ({ collection }) => { const [isModified, setIsModified] = useState(false); - const { environments } = collection; const [selectedEnvironment, setSelectedEnvironment] = useState(null); const [tab, setTab] = useState('default'); const [showExportModal, setShowExportModal] = useState(false); + + const environments = collection?.environments || []; + if (!environments || !environments.length) { return ( - - {tab === 'create' ? ( - setTab('default')} /> - ) : tab === 'import' ? ( - setTab('default')} /> - ) : ( - - )} - + {tab === 'create' ? ( + setTab('default')} /> + ) : tab === 'import' ? ( + setTab('default')} /> + ) : ( + + )} ); } return ( - - - + {showExportModal && ( setShowExportModal(false)} - environments={collection.environments} + environments={environments} environmentType="collection" /> )} diff --git a/packages/bruno-app/src/components/Environments/GlobalEnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/GlobalEnvironmentSettings/index.js new file mode 100644 index 000000000..6a5790a7a --- /dev/null +++ b/packages/bruno-app/src/components/Environments/GlobalEnvironmentSettings/index.js @@ -0,0 +1,8 @@ +import React from 'react'; +import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments'; + +const GlobalEnvironmentSettings = () => { + return ; +}; + +export default GlobalEnvironmentSettings; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js deleted file mode 100644 index cb7510c7d..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import Modal from 'components/Modal/index'; -import Portal from 'components/Portal/index'; -import { useFormik } from 'formik'; -import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; -import { useEffect, useRef } from 'react'; -import toast from 'react-hot-toast'; -import { useDispatch } from 'react-redux'; -import * as Yup from 'yup'; - -const CopyEnvironment = ({ environment, onClose }) => { - const dispatch = useDispatch(); - const inputRef = useRef(); - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - name: environment.name + ' - Copy' - }, - validationSchema: Yup.object({ - name: Yup.string() - .min(1, 'must be at least 1 character') - .max(50, 'must be 50 characters or less') - .required('name is required') - }), - onSubmit: (values) => { - dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid })) - .then(() => { - toast.success('Global environment created!'); - onClose(); - }) - .catch((error) => { - toast.error('An error occurred while created the environment'); - console.error(error); - }); - } - }); - - useEffect(() => { - if (inputRef && inputRef.current) { - inputRef.current.focus(); - } - }, [inputRef]); - - const onSubmit = () => { - formik.handleSubmit(); - }; - - return ( - - -
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 deleted file mode 100644 index e6156baf1..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import toast from 'react-hot-toast'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; -import { useDispatch, useSelector } from 'react-redux'; -import Portal from 'components/Portal'; -import Modal from 'components/Modal'; -import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; -import { validateName, validateNameError } from 'utils/common/regex'; - -const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => { - const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments); - - const validateEnvironmentName = (name) => { - const trimmedName = name?.toLowerCase().trim(); - return globalEnvs.every((env) => env?.name?.toLowerCase().trim() !== trimmedName); - }; - - const dispatch = useDispatch(); - const inputRef = useRef(); - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - name: '' - }, - validationSchema: Yup.object({ - name: Yup.string() - .min(1, 'Must be at least 1 character') - .max(255, 'Must be 255 characters or less') - .test('is-valid-filename', function (value) { - const isValid = validateName(value); - return isValid ? true : this.createError({ message: validateNameError(value) }); - }) - .required('Name is required') - .test('duplicate-name', 'Global Environment already exists', validateEnvironmentName) - }), - onSubmit: (values) => { - dispatch(addGlobalEnvironment({ name: values.name })) - .then(() => { - toast.success('Global environment created!'); - onClose(); - // Call the callback if provided - if (onEnvironmentCreated) { - onEnvironmentCreated(); - } - }) - .catch(() => toast.error('An error occurred while creating the environment')); - } - }); - - useEffect(() => { - if (inputRef && inputRef.current) { - inputRef.current.focus(); - } - }, [inputRef]); - - const onSubmit = () => { - formik.handleSubmit(); - }; - - return ( - - -
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 deleted file mode 100644 index 48b874214..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js +++ /dev/null @@ -1,15 +0,0 @@ -import styled from 'styled-components'; - -const Wrapper = styled.div` - button.submit { - color: white; - background-color: var(--color-background-danger) !important; - border: inherit !important; - - &:hover { - border: inherit !important; - } - } -`; - -export default Wrapper; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js deleted file mode 100644 index ff81e8be2..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import Portal from 'components/Portal/index'; -import toast from 'react-hot-toast'; -import Modal from 'components/Modal/index'; -import { useDispatch } from 'react-redux'; -import StyledWrapper from './StyledWrapper'; -import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; - -const DeleteEnvironment = ({ onClose, environment }) => { - const dispatch = useDispatch(); - const onConfirm = () => { - dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid })) - .then(() => { - toast.success('Global Environment deleted successfully'); - onClose(); - }) - .catch(() => toast.error('An error occurred while deleting the environment')); - }; - - return ( - - - - 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 deleted file mode 100644 index 6a587b4fc..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { IconAlertTriangle } from '@tabler/icons'; -import Modal from 'components/Modal'; -import { createPortal } from 'react-dom'; - -const ConfirmSwitchEnv = ({ onCancel }) => { - return createPortal( - { - 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 deleted file mode 100644 index 0dc900d13..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js +++ /dev/null @@ -1,67 +0,0 @@ -import styled from 'styled-components'; - -const Wrapper = styled.div` - table { - width: 100%; - border-collapse: collapse; - font-weight: 500; - table-layout: fixed; - - thead, - td { - border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}; - padding: 4px 10px; - vertical-align: middle; - - &:nth-child(1), - &:nth-child(4) { - width: 70px; - } - &:nth-child(5) { - width: 40px; - } - - &:nth-child(2) { - width: 25%; - } - } - - thead { - color: ${(props) => props.theme.table.thead.color}; - font-size: ${(props) => props.theme.font.size.base}; - user-select: none; - } - thead td { - padding: 6px 10px; - } - } - - .btn-add-param { - font-size: ${(props) => props.theme.font.size.base}; - } - - .tooltip-mod { - font-size: ${(props) => props.theme.font.size.xs} !important; - width: 150px !important; - } - - input[type='text'] { - width: 100%; - border: solid 1px transparent; - outline: none !important; - background-color: transparent; - - &:focus { - outline: none !important; - border: solid 1px transparent; - } - } - - input[type='checkbox'] { - cursor: pointer; - vertical-align: middle; - margin: 0; - } -`; - -export default Wrapper; 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 deleted file mode 100644 index 6107bc59c..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ /dev/null @@ -1,224 +0,0 @@ -import React, { useRef, useEffect } from 'react'; -import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons'; -import { useTheme } from 'providers/Theme'; -import { useDispatch, useSelector } from 'react-redux'; -import MultiLineEditor from 'components/MultiLineEditor/index'; -import StyledWrapper from './StyledWrapper'; -import { uuid } from 'utils/common'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; -import { variableNameRegex } from 'utils/common/regex'; -import toast from 'react-hot-toast'; -import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; -import { Tooltip } from 'react-tooltip'; -import { getGlobalEnvironmentVariables } from 'utils/collections'; - -const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => { - const dispatch = useDispatch(); - const { storedTheme } = useTheme(); - const addButtonRef = useRef(null); - const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); - - let _collection = cloneDeep(collection); - - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - _collection.globalEnvironmentVariables = globalEnvironmentVariables; - - const formik = useFormik({ - enableReinitialize: true, - initialValues: environment.variables || [], - validationSchema: Yup.array().of( - Yup.object({ - enabled: Yup.boolean(), - name: Yup.string() - .required('Name cannot be empty') - .matches( - variableNameRegex, - 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.' - ) - .trim(), - secret: Yup.boolean(), - type: Yup.string(), - uid: Yup.string(), - value: Yup.mixed().nullable() - }) - ), - onSubmit: (values) => { - if (!formik.dirty) { - toast.error('Nothing to save'); - return; - } - - dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(values) })) - .then(() => { - toast.success('Changes saved successfully'); - formik.resetForm({ values }); - setIsModified(false); - }) - .catch((error) => { - console.error(error); - toast.error('An error occurred while saving the changes'); - }); - } - }); - - // Effect to track modifications. - React.useEffect(() => { - setIsModified(formik.dirty); - }, [formik.dirty]); - - const ErrorMessage = ({ name }) => { - const meta = formik.getFieldMeta(name); - const id = uuid(); - if (!meta.error || !meta.touched) { - return null; - } - return ( - - - - - ); - }; - - 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)} - enableBrunoVarInfo={false} - /> -
- {typeof variable.value !== 'string' && ( - - - - - )} -
- - - -
-
- -
-
- -
- - -
-
- ); -}; -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 deleted file mode 100644 index d88cb9c65..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js +++ /dev/null @@ -1,58 +0,0 @@ -import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons'; -import { useState } from 'react'; -import CopyEnvironment from '../../CopyEnvironment'; -import DeleteEnvironment from '../../DeleteEnvironment'; -import RenameEnvironment from '../../RenameEnvironment'; -import EnvironmentVariables from './EnvironmentVariables'; -import ToolHint from 'components/ToolHint/index'; - -const EnvironmentDetails = ({ environment, setIsModified, collection, allEnvironments }) => { - const [openEditModal, setOpenEditModal] = useState(false); - const [openDeleteModal, setOpenDeleteModal] = useState(false); - const [openCopyModal, setOpenCopyModal] = useState(false); - - return ( -
- {openEditModal && ( - setOpenEditModal(false)} environment={environment} /> - )} - {openDeleteModal && ( - setOpenDeleteModal(false)} - environment={environment} - /> - )} - {openCopyModal && ( - setOpenCopyModal(false)} environment={environment} /> - )} -
-
- - {environment.name} -
-
- - setOpenEditModal(true)} /> - - - setOpenCopyModal(true)} /> - - - setOpenDeleteModal(true)} data-testid="delete-environment-button" /> - -
-
- -
- -
-
- ); -}; - -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 deleted file mode 100644 index dd9761532..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ /dev/null @@ -1,62 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - margin-inline: -1rem; - margin-block: -1.5rem; - - background-color: ${(props) => props.theme.collection.environment.settings.bg}; - - .environments-sidebar { - background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg}; - border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight}; - min-height: 400px; - height: 100%; - max-height: 85vh; - overflow-y: auto; - } - - .environment-item { - min-width: 150px; - display: block; - position: relative; - cursor: pointer; - padding: 8px 10px; - border-left: solid 2px transparent; - text-decoration: none; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - &:hover { - text-decoration: none; - background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg}; - } - } - - .active { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important; - border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border}; - &:hover { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important; - } - } - - .btn-create-environment, - .btn-import-environment { - padding: 8px 10px; - cursor: pointer; - border-bottom: none; - color: ${(props) => props.theme.textLink}; - - span:hover { - text-decoration: underline; - } - } - - .btn-import-environment { - color: ${(props) => props.theme.colors.text.muted}; - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js deleted file mode 100644 index c62be278e..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import usePrevious from 'hooks/usePrevious'; -import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from '../CreateEnvironment'; -import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons'; -import StyledWrapper from './StyledWrapper'; -import ConfirmSwitchEnv from './ConfirmSwitchEnv'; -import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index'; -import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; -import { isEqual } from 'lodash'; -import ToolHint from 'components/ToolHint/index'; - -const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => { - const [openCreateModal, setOpenCreateModal] = useState(false); - const [openImportModal, setOpenImportModal] = useState(false); - const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false); - - const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false); - const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]); - - const envUids = environments ? environments.map((env) => env.uid) : []; - const prevEnvUids = usePrevious(envUids); - - useEffect(() => { - if (!environments?.length) { - setSelectedEnvironment(null); - setOriginalEnvironmentVariables([]); - return; - } - - if (selectedEnvironment) { - const _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid); - const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment); - if (hasSelectedEnvironmentChanged) { - setSelectedEnvironment(_selectedEnvironment); - } - setOriginalEnvironmentVariables(selectedEnvironment.variables); - return; - } - - const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0] || null; - - setSelectedEnvironment(environment); - setOriginalEnvironmentVariables(environment?.variables || []); - }, [environments, activeEnvironmentUid]); - - useEffect(() => { - if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) { - const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid)); - if (newEnv) { - setSelectedEnvironment(newEnv); - } - } - - if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) { - setSelectedEnvironment(environments && environments.length ? environments[0] : null); - } - }, [envUids, environments, prevEnvUids]); - - const handleEnvironmentClick = (env) => { - if (!isModified) { - setSelectedEnvironment(env); - } else { - setSwitchEnvConfirmClose(true); - } - }; - - if (!selectedEnvironment) { - return null; - } - - const handleCreateEnvClick = () => { - if (!isModified) { - setOpenCreateModal(true); - } else { - setSwitchEnvConfirmClose(true); - } - }; - - const handleImportClick = () => { - if (!isModified) { - setOpenImportModal(true); - } else { - setSwitchEnvConfirmClose(true); - } - }; - - const handleSecretsClick = () => { - setOpenManageSecretsModal(true); - }; - - const handleExportClick = () => { - if (setShowExportModal) { - setShowExportModal(true); - } - }; - - const handleConfirmSwitch = (saveChanges) => { - if (!saveChanges) { - setSwitchEnvConfirmClose(false); - } - }; - - return ( - - {openCreateModal && setOpenCreateModal(false)} />} - {openImportModal && setOpenImportModal(false)} />} - {openManageSecretsModal && setOpenManageSecretsModal(false)} />} - -
-
- {switchEnvConfirmClose && ( -
- handleConfirmSwitch(false)} /> -
- )} -
- {environments - && environments.length - && environments.map((env) => ( - -
handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle click - > - {env.name} -
-
- ))} -
handleCreateEnvClick()}> - + Create -
- -
-
handleImportClick()}> - - Import -
-
handleExportClick()}> - - Export -
-
handleSecretsClick()}> - - Managing Secrets -
-
-
-
- -
-
- ); -}; - -export default EnvironmentList; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js deleted file mode 100644 index a6193de97..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import Portal from 'components/Portal/index'; -import Modal from 'components/Modal/index'; -import toast from 'react-hot-toast'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; -import { useDispatch } from 'react-redux'; -import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; -import { validateName, validateNameError } from 'utils/common/regex'; - -const RenameEnvironment = ({ onClose, environment }) => { - const dispatch = useDispatch(); - const inputRef = useRef(); - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - name: environment.name - }, - validationSchema: Yup.object({ - name: Yup.string() - .min(1, 'must be at least 1 character') - .max(255, 'Must be 255 characters or less') - .test('is-valid-filename', function (value) { - const isValid = validateName(value); - return isValid ? true : this.createError({ message: validateNameError(value) }); - }) - .required('name is required') - }), - onSubmit: (values) => { - if (values.name === environment.name) { - return; - } - dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid })) - .then(() => { - toast.success('Environment renamed successfully'); - onClose(); - }) - .catch((error) => { - toast.error('An error occurred while renaming the environment'); - console.error(error); - }); - } - }); - - useEffect(() => { - if (inputRef && inputRef.current) { - inputRef.current.focus(); - } - }, [inputRef]); - - const onSubmit = () => { - formik.handleSubmit(); - }; - - return ( - - -
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 deleted file mode 100644 index 2dfad0cfe..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/StyledWrapper.js +++ /dev/null @@ -1,13 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - button.btn-create-environment { - &:hover { - span { - text-decoration: underline; - } - } - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js deleted file mode 100644 index 37eace0a9..000000000 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js +++ /dev/null @@ -1,90 +0,0 @@ -import Modal from 'components/Modal/index'; -import React, { useState } from 'react'; -import CreateEnvironment from './CreateEnvironment'; -import EnvironmentList from './EnvironmentList'; -import StyledWrapper from './StyledWrapper'; -import { IconFileAlert } from '@tabler/icons'; -import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; -import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal'; - -export const SharedButton = ({ children, className, onClick }) => { - return ( - - ); -}; - -const DefaultTab = ({ setTab }) => { - return ( -
- - No Global Environments found -
- setTab('create')}> - Create Global Environment - - - Or - - setTab('import')}> - Import Environment - -
-
- ); -}; - -const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvironmentUid, onClose }) => { - const [isModified, setIsModified] = useState(false); - const environments = globalEnvironments; - const [selectedEnvironment, setSelectedEnvironment] = useState(null); - const [tab, setTab] = useState('default'); - const [showExportModal, setShowExportModal] = useState(false); - if (!environments || !environments.length) { - return ( - - - {tab === 'create' ? ( - setTab('default')} /> - ) : tab === 'import' ? ( - setTab('default')} /> - ) : ( - - )} - - - ); - } - - return ( - - - - - {showExportModal && ( - setShowExportModal(false)} - environments={globalEnvironments} - environmentType="global" - /> - )} - - ); -}; - -export default EnvironmentSettings; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index c438ef9dd..6b4d0b409 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -34,6 +34,8 @@ import WSResponsePane from 'components/ResponsePane/WsResponsePane'; import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index'; import ResponseExample from 'components/ResponseExample'; import WorkspaceHome from 'components/WorkspaceHome'; +import EnvironmentSettings from 'components/Environments/EnvironmentSettings'; +import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 480; @@ -147,8 +149,6 @@ const RequestTabPanel = () => { }; }, [handleMouseUp, handleMouseMove]); - // When devtools opens in vertical layout, reduce request pane height to ensure response pane is visible - // When devtools closes, restore the previous height useEffect(() => { if (!isVerticalLayout) return; @@ -171,11 +171,15 @@ const RequestTabPanel = () => { } }, [isConsoleOpen, isVerticalLayout]); - if (!activeTabUid) { + if (!activeTabUid || !focusedTab) { return ; } - if (!focusedTab || !focusedTab.uid || !focusedTab.collectionUid) { + if (focusedTab.type === 'global-environment-settings') { + return ; + } + + if (!focusedTab.uid || !focusedTab.collectionUid) { return
An error occurred!
; } @@ -226,6 +230,10 @@ const RequestTabPanel = () => { return ; } + if (focusedTab.type === 'environment-settings') { + return ; + } + if (!item || !item.uid) { return ; } diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js index 0ea63e4fb..a85fd4ea4 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js @@ -1,6 +1,6 @@ import React from 'react'; import GradientCloseButton from './GradientCloseButton'; -import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons'; +import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons'; const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => { const getTabInfo = (type, tabName) => { @@ -53,6 +53,22 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra ); } + case 'environment-settings': { + return ( + <> + + Environments + + ); + } + case 'global-environment-settings': { + return ( + <> + + Global Environments + + ); + } } }; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 18633c2da..8998ca9cc 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,16 +1,19 @@ import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react'; import get from 'lodash/get'; import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; -import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; -import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft } from 'providers/ReduxStore/slices/collections'; +import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; +import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; +import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { useTheme } from 'providers/Theme'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import darkTheme from 'themes/dark'; import lightTheme from 'themes/light'; import { findItemInCollection, hasRequestChanges } from 'utils/collections'; import ConfirmRequestClose from './ConfirmRequestClose'; import ConfirmCollectionClose from './ConfirmCollectionClose'; import ConfirmFolderClose from './ConfirmFolderClose'; +import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnvironment'; import RequestTabNotFound from './RequestTabNotFound'; import SpecialTab from './SpecialTab'; import StyledWrapper from './StyledWrapper'; @@ -21,6 +24,7 @@ import GradientCloseButton from './GradientCloseButton'; import { flattenItems } from 'utils/collections/index'; import { closeWsConnection } from 'utils/network/index'; import ExampleTab from '../ExampleTab'; +import toast from 'react-hot-toast'; const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => { const dispatch = useDispatch(); @@ -31,6 +35,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const [showConfirmClose, setShowConfirmClose] = useState(false); const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false); const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false); + const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false); + const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false); const dropdownTippyRef = useRef(); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); @@ -152,8 +158,31 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const hasDraft = tab.type === 'collection-settings' && collection?.draft; const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft; + const hasEnvironmentDraft = tab.type === 'environment-settings' && collection?.environmentsDraft; + const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft); + const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft; - if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) { + const handleCloseEnvironmentSettings = (event) => { + if (!collection?.environmentsDraft) { + return handleCloseClick(event); + } + + event.stopPropagation(); + event.preventDefault(); + setShowConfirmEnvironmentClose(true); + }; + + const handleCloseGlobalEnvironmentSettings = (event) => { + if (!globalEnvironmentDraft) { + return handleCloseClick(event); + } + + event.stopPropagation(); + event.preventDefault(); + setShowConfirmGlobalEnvironmentClose(true); + }; + + if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings', 'environment-settings', 'global-environment-settings'].includes(tab.type)) { return ( )} + {showConfirmEnvironmentClose && tab.type === 'environment-settings' && ( + setShowConfirmEnvironmentClose(false)} + onCloseWithoutSave={() => { + dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); + dispatch(closeTabs({ tabUids: [tab.uid] })); + setShowConfirmEnvironmentClose(false); + }} + onSaveAndClose={() => { + const draft = collection.environmentsDraft; + if (draft?.environmentUid && draft?.variables) { + dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid)) + .then(() => { + dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); + dispatch(closeTabs({ tabUids: [tab.uid] })); + setShowConfirmEnvironmentClose(false); + toast.success('Environment saved'); + }) + .catch((err) => { + console.log('err', err); + toast.error('Failed to save environment'); + }); + } + }} + /> + )} + {showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && ( + setShowConfirmGlobalEnvironmentClose(false)} + onCloseWithoutSave={() => { + dispatch(clearGlobalEnvironmentDraft()); + dispatch(closeTabs({ tabUids: [tab.uid] })); + setShowConfirmGlobalEnvironmentClose(false); + }} + onSaveAndClose={() => { + const draft = globalEnvironmentDraft; + if (draft?.environmentUid && draft?.variables) { + dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })) + .then(() => { + dispatch(clearGlobalEnvironmentDraft()); + dispatch(closeTabs({ tabUids: [tab.uid] })); + setShowConfirmGlobalEnvironmentClose(false); + toast.success('Global environment saved'); + }) + .catch((err) => { + console.log('err', err); + toast.error('Failed to save global environment'); + }); + } + }} + /> + )} {tab.type === 'folder-settings' && !folder ? ( ) : tab.type === 'folder-settings' ? ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} /> ) : tab.type === 'collection-settings' ? ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={collection?.name} hasDraft={hasDraft} /> + ) : tab.type === 'environment-settings' ? ( + dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} /> + ) : tab.type === 'global-environment-settings' ? ( + dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} /> ) : ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} /> )} diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index 73f34b007..647d0cb69 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -83,10 +83,6 @@ const RequestTabs = () => { return null; } - if (!activeTab) { - return Something went wrong!; - } - const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth; const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment/index.js index a286108ad..e199de25e 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment/index.js @@ -32,12 +32,12 @@ const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => { return isValid ? true : this.createError({ message: validateNameError(value) }); }) .required('Name is required') - .test('duplicate-name', 'Environment already exists', validateEnvironmentName) + .test('duplicate-name', 'Global environment already exists', validateEnvironmentName) }), onSubmit: (values) => { dispatch(addGlobalEnvironment({ name: values.name })) .then(() => { - toast.success('Environment created!'); + toast.success('Global environment created!'); onClose(); // Call the callback if provided if (onEnvironmentCreated) { @@ -62,7 +62,7 @@ const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => { props.theme.bg}; flex-shrink: 0; display: flex; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index 515d9f1fc..a93424133 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useRef } from 'react'; import cloneDeep from 'lodash/cloneDeep'; import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons'; import { useTheme } from 'providers/Theme'; @@ -10,14 +10,26 @@ import { useFormik } from 'formik'; import * as Yup from 'yup'; import { variableNameRegex } from 'utils/common/regex'; import toast from 'react-hot-toast'; -import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import { + saveGlobalEnvironment, + setGlobalEnvironmentDraft, + clearGlobalEnvironmentDraft +} from 'providers/ReduxStore/slices/global-environments'; import { Tooltip } from 'react-tooltip'; import { getGlobalEnvironmentVariables } from 'utils/collections'; const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); + const { globalEnvironments, activeGlobalEnvironmentUid, globalEnvironmentDraft } = useSelector( + (state) => state.globalEnvironments + ); + + const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid; + + // Track environment changes for draft restoration + const prevEnvUidRef = React.useRef(null); + const mountedRef = React.useRef(false); let _collection = collection ? cloneDeep(collection) : {}; @@ -26,6 +38,8 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV _collection.globalEnvironmentVariables = globalEnvironmentVariables; } + // Initial values based only on saved environment variables (not draft) + // Draft restoration happens in a separate effect to avoid infinite loops const initialValues = React.useMemo(() => { const vars = environment.variables || []; return [ @@ -67,12 +81,10 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV const isLastRow = index === values.length - 1; const isEmptyRow = !variable.name || variable.name.trim() === ''; - // Skip validation for the last empty row if (isLastRow && isEmptyRow) { return; } - // Validate name for non-empty rows if (!variable.name || variable.name.trim() === '') { if (!errors[index]) errors[index] = {}; errors[index].name = 'Name cannot be empty'; @@ -86,20 +98,61 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV onSubmit: () => {} }); + // Restore draft values on mount or environment switch + React.useEffect(() => { + const isMount = !mountedRef.current; + const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid; + + prevEnvUidRef.current = environment.uid; + mountedRef.current = true; + + if ((isMount || envChanged) && hasDraftForThisEnv && globalEnvironmentDraft?.variables) { + formik.setValues([ + ...globalEnvironmentDraft.variables, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]); + } + }, [environment.uid, hasDraftForThisEnv, globalEnvironmentDraft?.variables]); + + // Sync draft state to Redux React.useEffect(() => { const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); const savedValues = environment.variables || []; - const hasActualChanges = JSON.stringify(currentValues) !== JSON.stringify(savedValues); + const currentValuesJson = JSON.stringify(currentValues); + const savedValuesJson = JSON.stringify(savedValues); + const hasActualChanges = currentValuesJson !== savedValuesJson; setIsModified(hasActualChanges); - }, [formik.values, environment.variables, setIsModified]); + + // Get existing draft for comparison + const existingDraftVariables = hasDraftForThisEnv ? globalEnvironmentDraft?.variables : null; + const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null; + + if (hasActualChanges) { + // Only dispatch if draft values are actually different + if (currentValuesJson !== existingDraftJson) { + dispatch(setGlobalEnvironmentDraft({ + environmentUid: environment.uid, + variables: currentValues + })); + } + } else if (hasDraftForThisEnv) { + dispatch(clearGlobalEnvironmentDraft()); + } + }, [formik.values, environment.variables, environment.uid, setIsModified, dispatch, hasDraftForThisEnv, globalEnvironmentDraft?.variables]); const ErrorMessage = ({ name, index }) => { const meta = formik.getFieldMeta(name); const id = `error-${name}-${index}`; - // Don't show error for the last empty row const isLastRow = index === formik.values.length - 1; const variable = formik.values[index]; const isEmptyRow = !variable?.name || variable.name.trim() === ''; @@ -119,39 +172,47 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV ); }; - const handleRemoveVar = (id) => { - const filteredValues = formik.values.filter((variable) => variable.uid !== id); + const handleRemoveVar = useCallback((id) => { + const currentValues = formik.values; - const lastRow = formik.values[formik.values.length - 1]; - const isLastEmptyRow = lastRow.uid === id && (!lastRow.name || lastRow.name.trim() === ''); + if (!currentValues || currentValues.length === 0) { + return; + } + + const lastRow = currentValues[currentValues.length - 1]; + const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === ''); if (isLastEmptyRow) { return; } + const filteredValues = currentValues.filter((variable) => variable.uid !== id); + const hasEmptyLastRow = filteredValues.length > 0 && (!filteredValues[filteredValues.length - 1].name || filteredValues[filteredValues.length - 1].name.trim() === ''); - if (!hasEmptyLastRow) { - filteredValues.push({ - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - }); - } + const newValues = hasEmptyLastRow + ? filteredValues + : [ + ...filteredValues, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; - formik.setValues(filteredValues); - }; + formik.setValues(newValues); + }, [formik.values]); const handleNameChange = (index, e) => { formik.handleChange(e); const isLastRow = index === formik.values.length - 1; - // If typing in the last row, add a new empty row if (isLastRow) { const newVariable = { uid: uuid(), @@ -161,15 +222,32 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV secret: false, enabled: true }; - // Use setTimeout to ensure the change is processed first setTimeout(() => { formik.setFieldValue(formik.values.length, newVariable, false); }, 0); } }; + const handleNameBlur = (index) => { + formik.setFieldTouched(`${index}.name`, true, true); + }; + + const handleNameKeyDown = (index, e) => { + if (e.key === 'Enter') { + e.preventDefault(); + formik.setFieldTouched(`${index}.name`, true, true); + } + }; + const handleSave = () => { const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const savedValues = environment.variables || []; + + const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues); + if (!hasChanges) { + toast.error('No changes to save'); + return; + } const hasValidationErrors = variablesToSave.some((variable) => { if (!variable.name || variable.name.trim() === '') { @@ -223,8 +301,24 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV } ]; formik.resetForm({ values: resetValues }); + setIsModified(false); }; + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + React.useEffect(() => { + const handleSaveEvent = () => { + handleSaveRef.current(); + }; + + window.addEventListener('environment-save', handleSaveEvent); + + return () => { + window.removeEventListener('environment-save', handleSaveEvent); + }; + }, []); + return (
@@ -271,6 +365,8 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV value={variable.name} placeholder={isLastEmptyRow ? 'Name' : ''} onChange={(e) => handleNameChange(index, e)} + onBlur={() => handleNameBlur(index)} + onKeyDown={(e) => handleNameKeyDown(index, e)} />
@@ -286,6 +382,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV isSecret={variable.secret} readOnly={typeof variable.value !== 'string'} onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)} + onSave={handleSave} />
{typeof variable.value !== 'string' && ( @@ -341,4 +438,5 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV ); }; + export default EnvironmentVariables; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js index d4b26793d..6cb5e3ed9 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js @@ -81,7 +81,7 @@ const StyledWrapper = styled.div` .title-error { position: absolute; top: 100%; - left: 0; + left: 20px; margin-top: 4px; padding: 4px 8px; font-size: 11px; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js index 0f60f349a..750863fb3 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js @@ -3,6 +3,7 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` display: flex; height: 100%; + overflow: hidden; background-color: ${(props) => props.theme.bg}; position: relative; @@ -10,6 +11,7 @@ const StyledWrapper = styled.div` display: flex; height: 100%; width: 100%; + overflow: hidden; } .confirm-switch-overlay { diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/StyledWrapper.js index 7931f4e94..95e8137a1 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/StyledWrapper.js @@ -4,6 +4,7 @@ const StyledWrapper = styled.div` height: 100%; display: flex; flex-direction: column; + overflow: hidden; background-color: ${(props) => props.theme.bg}; .empty-state { diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js index 5479e1c17..d28a2c4ab 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/index.js @@ -145,7 +145,7 @@ const WorkspaceHome = () => { const tabs = [ { id: 'overview', label: 'Overview' }, - { id: 'environments', label: 'Environments' } + { id: 'environments', label: 'Global Environments' } ]; const renderTabContent = () => { diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index 32aa8be21..e53927833 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -3,7 +3,6 @@ import toast from 'react-hot-toast'; import find from 'lodash/find'; import Mousetrap from 'mousetrap'; import { useSelector, useDispatch } from 'react-redux'; -import EnvironmentSettings from 'components/Environments/EnvironmentSettings'; import NetworkError from 'components/ResponsePane/NetworkError'; import NewRequest from 'components/Sidebar/NewRequest'; import GlobalSearchModal from 'components/GlobalSearchModal'; @@ -15,7 +14,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import { findCollectionByUid, findItemInCollection } from 'utils/collections'; -import { closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; +import { addTab, closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; import { getKeyBindingsForActionAllOS } from './keyMappings'; @@ -26,9 +25,6 @@ export const HotkeysProvider = (props) => { const tabs = useSelector((state) => state.tabs.tabs); const collections = useSelector((state) => state.collections.collections); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen); - const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen); - const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false); const [showNewRequestModal, setShowNewRequestModal] = useState(false); const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false); @@ -44,23 +40,24 @@ export const HotkeysProvider = (props) => { // save hotkey useEffect(() => { Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => { - if (isEnvironmentSettingsModalOpen || isGlobalEnvironmentSettingsModalOpen) { - console.log('todo: save environment settings'); - } else { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); - if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); - if (collection) { - const item = findItemInCollection(collection, activeTab.uid); - if (item && item.uid) { - if (activeTab.type === 'folder-settings') { - dispatch(saveFolderRoot(collection.uid, item.uid)); - } else { - dispatch(saveRequest(activeTab.uid, activeTab.collectionUid)); - } - } else if (activeTab.type === 'collection-settings') { - dispatch(saveCollectionSettings(collection.uid)); + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + if (activeTab) { + if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') { + window.dispatchEvent(new CustomEvent('environment-save')); + return false; + } + + const collection = findCollectionByUid(collections, activeTab.collectionUid); + if (collection) { + const item = findItemInCollection(collection, activeTab.uid); + if (item && item.uid) { + if (activeTab.type === 'folder-settings') { + dispatch(saveFolderRoot(collection.uid, item.uid)); + } else { + dispatch(saveRequest(activeTab.uid, activeTab.collectionUid)); } + } else if (activeTab.type === 'collection-settings') { + dispatch(saveCollectionSettings(collection.uid)); } } } @@ -71,7 +68,7 @@ export const HotkeysProvider = (props) => { return () => { Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]); }; - }, [activeTabUid, tabs, saveRequest, collections, isEnvironmentSettingsModalOpen, isGlobalEnvironmentSettingsModalOpen]); + }, [activeTabUid, tabs, saveRequest, collections, dispatch]); // send request (ctrl/cmd + enter) useEffect(() => { @@ -120,7 +117,13 @@ export const HotkeysProvider = (props) => { const collection = findCollectionByUid(collections, activeTab.collectionUid); if (collection) { - setShowEnvSettingsModal(true); + dispatch( + addTab({ + uid: `${collection.uid}-environment-settings`, + collectionUid: collection.uid, + type: 'environment-settings' + }) + ); } } @@ -130,7 +133,7 @@ export const HotkeysProvider = (props) => { return () => { Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]); }; - }, [activeTabUid, tabs, collections, setShowEnvSettingsModal]); + }, [activeTabUid, tabs, collections, dispatch]); // new request (ctrl/cmd + b) useEffect(() => { @@ -281,9 +284,6 @@ export const HotkeysProvider = (props) => { return ( - {showEnvSettingsModal && ( - setShowEnvSettingsModal(false)} /> - )} {showNewRequestModal && ( setShowNewRequestModal(false)} /> )} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 045143a08..f43e27a00 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -69,12 +69,6 @@ export const appSlice = createSlice({ updateIsDragging: (state, action) => { state.isDragging = action.payload.isDragging; }, - updateEnvironmentSettingsModalVisibility: (state, action) => { - state.isEnvironmentSettingsModalOpen = action.payload; - }, - updateGlobalEnvironmentSettingsModalVisibility: (state, action) => { - state.isGlobalEnvironmentSettingsModalOpen = action.payload; - }, showHomePage: (state) => { state.showHomePage = true; state.showApiSpecPage = false; @@ -141,8 +135,6 @@ export const { refreshScreenWidth, updateLeftSidebarWidth, updateIsDragging, - updateEnvironmentSettingsModalVisibility, - updateGlobalEnvironmentSettingsModalVisibility, showHomePage, hideHomePage, showManageWorkspacePage, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 8dcfb3f56..f07d17f28 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -694,6 +694,19 @@ export const collectionsSlice = createSlice({ folder.draft = null; } }, + setEnvironmentsDraft: (state, action) => { + const { collectionUid, environmentUid, variables } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (collection) { + collection.environmentsDraft = { environmentUid, variables }; + } + }, + clearEnvironmentsDraft: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + if (collection) { + collection.environmentsDraft = null; + } + }, newEphemeralHttpRequest: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -3388,6 +3401,8 @@ export const { saveFolderDraft, deleteCollectionDraft, deleteFolderDraft, + setEnvironmentsDraft, + clearEnvironmentsDraft, newEphemeralHttpRequest, collapseFullCollection, toggleCollection, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js index 0e5503593..028c0db29 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js @@ -5,7 +5,8 @@ import { cloneDeep, has } from 'lodash'; const initialState = { globalEnvironments: [], - activeGlobalEnvironmentUid: null + activeGlobalEnvironmentUid: null, + globalEnvironmentDraft: null }; export const globalEnvironmentsSlice = createSlice({ @@ -73,6 +74,13 @@ export const globalEnvironmentsSlice = createSlice({ state.activeGlobalEnvironmentUid = null; } } + }, + setGlobalEnvironmentDraft: (state, action) => { + const { environmentUid, variables } = action.payload; + state.globalEnvironmentDraft = { environmentUid, variables }; + }, + clearGlobalEnvironmentDraft: (state) => { + state.globalEnvironmentDraft = null; } } }); @@ -84,7 +92,9 @@ export const { _renameGlobalEnvironment, _copyGlobalEnvironment, _selectGlobalEnvironment, - _deleteGlobalEnvironment + _deleteGlobalEnvironment, + setGlobalEnvironmentDraft, + clearGlobalEnvironmentDraft } = globalEnvironmentsSlice.actions; const getWorkspaceContext = (state) => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index a6ffc9e0f..fde898715 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -24,7 +24,9 @@ export const tabsSlice = createSlice({ const nonReplaceableTabTypes = [ 'variables', 'collection-runner', - 'security-settings' + 'security-settings', + 'environment-settings', + 'global-environment-settings' ]; const existingTab = find(state.tabs, (tab) => tab.uid === uid); diff --git a/packages/bruno-app/src/utils/common/regex.js b/packages/bruno-app/src/utils/common/regex.js index 8e7c796b5..16479cb4a 100644 --- a/packages/bruno-app/src/utils/common/regex.js +++ b/packages/bruno-app/src/utils/common/regex.js @@ -39,17 +39,17 @@ export const validateNameError = (name) => { } if (!firstCharacter.test(name[0])) { - return 'Invalid first character.'; + return `Special characters aren't allowed in the name. Invalid character '${name[0]}'.`; } for (let i = 1; i < name.length - 1; i++) { if (!middleCharacters.test(name[i])) { - return `Invalid character '${name[i]}' at position ${i + 1}.`; + return `Special characters aren't allowed in the name. Invalid character '${name[i]}'.`; } } if (!lastCharacter.test(name[name.length - 1])) { - return 'Invalid last character.'; + return `Special characters aren't allowed in the name. Invalid character '${name[name.length - 1]}'.`; } return ''; diff --git a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts index 04a796616..f1e424695 100644 --- a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts +++ b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts @@ -28,9 +28,14 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => { await page.getByTestId('environment-selector-trigger').click(); // open environment configuration await page.locator('#configure-env').click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); + await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible(); - await page.getByTestId('modal-close-button').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); // we restart the app to confirm that the environment variable is persisted const newApp = await restartApp(); @@ -43,11 +48,15 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => { // open environment dropdown await newPage.getByTestId('environment-selector-trigger').click(); await newPage.locator('#configure-env').click(); + + const newEnvTab = newPage.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(newEnvTab).toBeVisible(); + await expect(newPage.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible(); await expect(newPage.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible(); - // close the environment modal - await newPage.getByTestId('modal-close-button').click(); + await newEnvTab.hover(); + await newEnvTab.getByTestId('request-tab-close-icon').click(); // Restore the original Stage.bru file fs.writeFileSync(originalStageBruPath, originalStageBruContent); diff --git a/tests/environments/api-setEnvVar/api-setEnvVar-without-persist.spec.ts b/tests/environments/api-setEnvVar/api-setEnvVar-without-persist.spec.ts index 2972a738f..33ab045db 100644 --- a/tests/environments/api-setEnvVar/api-setEnvVar-without-persist.spec.ts +++ b/tests/environments/api-setEnvVar/api-setEnvVar-without-persist.spec.ts @@ -21,9 +21,14 @@ test.describe.serial('bru.setEnvVar(name, value)', () => { // confirm that the environment variable is set await page.getByTestId('environment-selector-trigger').click(); await page.locator('#configure-env').click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); + await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible(); - await page.getByTestId('modal-close-button').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); // we restart the app to confirm that the environment variable is not persisted const newApp = await restartApp(); @@ -37,11 +42,13 @@ test.describe.serial('bru.setEnvVar(name, value)', () => { await newPage.getByTestId('environment-selector-trigger').click(); await newPage.locator('#configure-env').click(); - // ensure that the environment variable is not persisted - await expect(newPage.locator('table.environment-variables tbody')).not.toContainText('token'); + const newEnvTab = newPage.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(newEnvTab).toBeVisible(); - // close the environment variable modal - await newPage.getByTestId('modal-close-button').click(); + await expect(newPage.locator('.table-container tbody')).not.toContainText('token'); + + await newEnvTab.hover(); + await newEnvTab.getByTestId('request-tab-close-icon').click(); await newPage.close(); }); }); diff --git a/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts b/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts index 083b08fc2..b4454e7cb 100644 --- a/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts +++ b/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts @@ -14,18 +14,20 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => { await page.locator('#configure-env').click(); await page.waitForTimeout(200); - // Remove the test environment variables + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + const key1Row = page.getByRole('row', { name: 'multiple-persist-vars-key1' }); if (await key1Row.isVisible()) { - await key1Row.getByRole('button').click(); // Click the delete button + await key1Row.getByRole('button').click(); } const key2Row = page.getByRole('row', { name: 'multiple-persist-vars-key2' }); if (await key2Row.isVisible()) { - await key2Row.getByRole('button').click(); // Click the delete button + await key2Row.getByRole('button').click(); } - await page.getByTestId('modal-close-button').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); } } catch (error) { // Ignore cleanup errors to avoid masking test failures @@ -74,11 +76,16 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => { await page.waitForTimeout(200); await page.locator('#configure-env').click(); await page.waitForTimeout(200); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); + await expect(page.getByRole('row', { name: 'multiple-persist-vars-key1' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'value1' }).getByRole('cell').nth(2)).toBeVisible(); await expect(page.getByRole('row', { name: 'multiple-persist-vars-key2' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'value2' }).getByRole('cell').nth(2)).toBeVisible(); - await page.getByTestId('modal-close-button').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); await test.step('Verify variables are persisted to file', async () => { diff --git a/tests/environments/collection-env-config-selection/collection-env-config-selection.spec.ts b/tests/environments/collection-env-config-selection/collection-env-config-selection.spec.ts index 521cdb483..42948cc77 100644 --- a/tests/environments/collection-env-config-selection/collection-env-config-selection.spec.ts +++ b/tests/environments/collection-env-config-selection/collection-env-config-selection.spec.ts @@ -28,15 +28,13 @@ test.describe('Collection Environment Configuration Selection Tests', () => { await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); - // Verify the config modal opens with the currently active environment selected - const collectionEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' }); - await expect(collectionEnvModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); - // Check that the active environment in the config matches prod - const activeEnvItem = collectionEnvModal.locator('.environment-item.active'); + const activeEnvItem = page.locator('.environment-item.active'); await expect(activeEnvItem).toContainText('prod'); - // Close the collection environment config modal - await page.getByText('×').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); diff --git a/tests/environments/create-environment/collection-env-create.spec.ts b/tests/environments/create-environment/collection-env-create.spec.ts index 01b0bd7cb..3c94b4d25 100644 --- a/tests/environments/create-environment/collection-env-create.spec.ts +++ b/tests/environments/create-environment/collection-env-create.spec.ts @@ -5,7 +5,6 @@ import { createEnvironment, addEnvironmentVariables, saveEnvironment, - closeEnvironmentPanel, sendRequest, expectResponseContains, removeCollection @@ -39,7 +38,6 @@ test.describe('Collection Environment Create Tests', () => { ]); await saveEnvironment(page); - await closeEnvironmentPanel(page); await expect(locators.environment.currentEnvironment()).toContainText('Test Environment'); }); diff --git a/tests/environments/create-environment/global-env-create.spec.ts b/tests/environments/create-environment/global-env-create.spec.ts index 49051f020..046ff9240 100644 --- a/tests/environments/create-environment/global-env-create.spec.ts +++ b/tests/environments/create-environment/global-env-create.spec.ts @@ -5,7 +5,6 @@ import { createEnvironment, addEnvironmentVariables, saveEnvironment, - closeEnvironmentPanel, sendRequest, expectResponseContains, closeAllCollections @@ -41,7 +40,6 @@ test.describe('Global Environment Create Tests', () => { ]); await saveEnvironment(page); - await closeEnvironmentPanel(page); await expect(locators.environment.currentEnvironment()).toContainText('Test Global Environment'); }); diff --git a/tests/environments/export-environment/collection-env-export/collection-env-export.spec.ts b/tests/environments/export-environment/collection-env-export/collection-env-export.spec.ts index 712cda34f..169a75e51 100644 --- a/tests/environments/export-environment/collection-env-export/collection-env-export.spec.ts +++ b/tests/environments/export-environment/collection-env-export/collection-env-export.spec.ts @@ -36,14 +36,13 @@ test.describe.serial('Collection Environment Export Tests', () => { await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); - // Verify the environment settings modal opens - const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' }); - await expect(envModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Open export modal and configure export settings', async () => { // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Verify export modal opens const exportModal = page.locator('.bruno-modal').filter({ hasText: 'Export Environments' }); @@ -63,8 +62,6 @@ test.describe.serial('Collection Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export the environment await page.getByRole('button', { name: 'Export 1 Environment' }).click(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported file and content', async () => { @@ -95,11 +92,14 @@ test.describe.serial('Collection Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for multiple environments', async () => { // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Verify all environments are selected by default await expect(page.getByRole('checkbox', { name: 'Local' })).toBeChecked(); @@ -115,8 +115,6 @@ test.describe.serial('Collection Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export all environments await page.getByRole('button', { name: /Export \d+ Environments?/ }).click(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported files and content', async () => { @@ -162,11 +160,14 @@ test.describe.serial('Collection Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings with folder format', async () => { // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Select folder export format (default might be single JSON file) await page.getByText('Separate files in folder').click(); @@ -178,8 +179,6 @@ test.describe.serial('Collection Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export should succeed with unique names await page.getByRole('button', { name: 'Export 2 Environment' }).click(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify unique naming and file content', async () => { @@ -219,11 +218,13 @@ test.describe.serial('Collection Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for single JSON file', async () => { - // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Deselect all environments first await page.getByText('Deselect All').click(); @@ -244,8 +245,6 @@ test.describe.serial('Collection Environment Export Tests', () => { // Verify success message await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported file and content', async () => { @@ -274,11 +273,13 @@ test.describe.serial('Collection Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for single JSON file', async () => { - // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Select single JSON file format await page.getByText('Single JSON file').click(); @@ -293,8 +294,6 @@ test.describe.serial('Collection Environment Export Tests', () => { // Verify success message await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported file and content', async () => { @@ -329,11 +328,13 @@ test.describe.serial('Collection Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for single JSON file', async () => { - // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Deselect all environments first await page.getByText('Deselect All').click(); @@ -351,8 +352,6 @@ test.describe.serial('Collection Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export should succeed with unique names await page.getByRole('button', { name: 'Export 1 Environment' }).click(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify unique naming and file content', async () => { @@ -387,11 +386,14 @@ test.describe.serial('Collection Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-collection').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Open export modal and deselect all environments', async () => { // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.getByRole('button', { name: 'Export Environment' }).click(); // Deselect all environments await page.getByText('Deselect All').click(); diff --git a/tests/environments/export-environment/global-env-export/global-env-export.spec.ts b/tests/environments/export-environment/global-env-export/global-env-export.spec.ts index 50f853936..bf0d39508 100644 --- a/tests/environments/export-environment/global-env-export/global-env-export.spec.ts +++ b/tests/environments/export-environment/global-env-export/global-env-export.spec.ts @@ -40,14 +40,13 @@ test.describe.serial('Global Environment Export Tests', () => { await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); - // Verify the global environment settings modal opens - const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' }); - await expect(globalEnvModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Open export modal and configure export settings', async () => { // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Verify export modal opens const exportModal = page.locator('.bruno-modal').filter({ hasText: 'Export Environments' }); @@ -67,8 +66,6 @@ test.describe.serial('Global Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export the environment await page.getByRole('button', { name: 'Export 1 Environment' }).click(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported file and content', async () => { @@ -99,11 +96,14 @@ test.describe.serial('Global Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for multiple environments', async () => { // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Verify all environments are selected by default await expect(page.getByRole('checkbox', { name: 'Local' })).toBeChecked(); @@ -119,8 +119,6 @@ test.describe.serial('Global Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export all environments await page.getByRole('button', { name: /Export \d+ Environments?/ }).click(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported files and content', async () => { @@ -166,11 +164,14 @@ test.describe.serial('Global Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings with folder format', async () => { // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Set export directory await page.locator('input[id="export-location"]').fill(exportDir); @@ -182,8 +183,6 @@ test.describe.serial('Global Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export should succeed with unique names await page.getByRole('button', { name: 'Export 2 Environment' }).click(); - - await page.getByTestId('modal-close-button').first().click(); }); await test.step('Verify unique naming and file content', async () => { @@ -223,11 +222,13 @@ test.describe.serial('Global Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for single JSON file', async () => { - // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Deselect all environments first await page.getByText('Deselect All').click(); @@ -250,8 +251,6 @@ test.describe.serial('Global Environment Export Tests', () => { // Verify success message await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported file and content', async () => { @@ -280,11 +279,13 @@ test.describe.serial('Global Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for single JSON file', async () => { - // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Select single JSON file format await page.getByText('Single JSON file').click(); @@ -299,8 +300,6 @@ test.describe.serial('Global Environment Export Tests', () => { await page.waitForTimeout(200); // Verify success message await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible(); - - await page.getByTestId('modal-close-button').click(); }); await test.step('Verify exported file and content', async () => { @@ -335,11 +334,13 @@ test.describe.serial('Global Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Configure export settings for single JSON file', async () => { - // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.locator('button[title="Export environment"]').click(); // Deselect all environments first await page.getByText('Deselect All').click(); @@ -359,8 +360,6 @@ test.describe.serial('Global Environment Export Tests', () => { await test.step('Execute export and close modal', async () => { // Export should succeed with unique names await page.getByRole('button', { name: 'Export 1 Environment' }).click(); - - await page.getByTestId('modal-close-button').first().click(); }); await test.step('Verify unique naming and file content', async () => { @@ -395,18 +394,18 @@ test.describe.serial('Global Environment Export Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Open export modal and deselect all environments', async () => { - // Click export button - await page.locator('.btn-import-environment').getByText('Export').click(); + await page.getByRole('button', { name: 'Export Environment' }).click(); - // Deselect all environments await page.getByText('Deselect All').click(); }); await test.step('Verify export button is disabled when no environments selected', async () => { - // Verify export button is disabled await expect(page.getByRole('button', { name: 'Export Environments' })).toBeDisabled(); }); }); diff --git a/tests/environments/global-env-config-selection/global-env-config-selection.spec.ts b/tests/environments/global-env-config-selection/global-env-config-selection.spec.ts index ae7513f96..d00245c63 100644 --- a/tests/environments/global-env-config-selection/global-env-config-selection.spec.ts +++ b/tests/environments/global-env-config-selection/global-env-config-selection.spec.ts @@ -21,15 +21,13 @@ test.describe('Global Environment Configuration Selection Tests', () => { await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); - // Verify the config modal opens with the currently active environment selected - const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' }); - await expect(globalEnvModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); - // Check that the active environment in the config matches the current environment - const activeEnvItem = globalEnvModal.locator('.environment-item.active'); + const activeEnvItem = page.locator('.environment-item.active'); await expect(activeEnvItem).toContainText(currentEnvName); - // Close the global environment config modal and go to welcome page - await page.getByText('×').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); diff --git a/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts b/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts index a7e544646..1dd030273 100644 --- a/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts +++ b/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts @@ -39,16 +39,14 @@ test.describe.serial('Collection Environment Import Tests', () => { }); await test.step('Verify imported environment and variables', async () => { - // The environment settings modal should now be visible with the imported environment - const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' }); - await expect(envModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); - // Verify imported variables await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); - // Close modal - await page.getByText('×').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); await test.step('Clean up after test', async () => { @@ -93,15 +91,11 @@ test.describe.serial('Collection Environment Import Tests', () => { const fileChooser = await fileChooserPromise; await fileChooser.setFiles(multiEnvFile); - // The environment settings modal should now be visible with the imported environments - const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' }); - await expect(envModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Verify both environments are available in selector', async () => { - // Check that both environments are available in the selector - await page.getByText('×').click(); // Close environment settings modal - await page.waitForTimeout(500); await page.getByTestId('environment-selector-trigger').click(); @@ -118,15 +112,16 @@ test.describe.serial('Collection Environment Import Tests', () => { // Verify prod environment variables by opening settings again await page.getByTestId('environment-selector-trigger').click(); await page.getByText('Configure', { exact: true }).click(); - const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' }); - await expect(envModal).toBeVisible(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); // Verify prod environment variables await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); - // Close modal - await page.getByText('×').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); await test.step('Clean up after test', async () => { diff --git a/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts b/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts index 39aebc79e..480af8412 100644 --- a/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts +++ b/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts @@ -23,12 +23,10 @@ test.describe.serial('Global Environment Import Tests', () => { // Delete all existing environments for (let i = 0; i < count; i++) { - await page.getByTestId('delete-environment-button').click(); - // Confirm deletion if there's a confirmation dialog + await page.locator('button[title="Delete"]').first().click(); const confirmButton = page.getByRole('button', { name: 'Delete' }); if (await confirmButton.isVisible()) { await confirmButton.click(); - await page.getByText('×').click(); } } @@ -56,16 +54,15 @@ test.describe.serial('Global Environment Import Tests', () => { }); await test.step('Verify imported global environment and variables', async () => { - // The global environment settings modal should now be visible with the imported environment - const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' }); - await expect(envModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); // Verify imported variables await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); - // Close modal - await page.getByText('×').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); @@ -90,12 +87,10 @@ test.describe.serial('Global Environment Import Tests', () => { // Delete all existing environments for (let i = 0; i < count; i++) { - await page.getByTestId('delete-environment-button').click(); - // Confirm deletion if there's a confirmation dialog - const confirmButton = page.getByRole('button', { name: 'Delete' }); + await page.locator('button[title="Delete"]').first().click(); + const confirmButton = page.getByText('Delete', { exact: true }); if (await confirmButton.isVisible()) { await confirmButton.click(); - await page.getByText('×').click(); } } @@ -120,14 +115,11 @@ test.describe.serial('Global Environment Import Tests', () => { const fileChooser = await fileChooserPromise; await fileChooser.setFiles(multiEnvFile); - // The global environment settings modal should now be visible with the imported environments - const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' }); - await expect(envModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); await test.step('Verify both global environments are available in selector', async () => { - // Check that both environments are available in the selector - await page.getByText('×').click(); // Close environment settings modal await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); @@ -145,15 +137,15 @@ test.describe.serial('Global Environment Import Tests', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); - const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' }); - await expect(envModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); // Verify imported variables await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); - // Close modal - await page.getByText('×').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); }); diff --git a/tests/environments/import-environment/collection-env-import.spec.ts b/tests/environments/import-environment/collection-env-import.spec.ts index 84ac2bd63..4e982c90a 100644 --- a/tests/environments/import-environment/collection-env-import.spec.ts +++ b/tests/environments/import-environment/collection-env-import.spec.ts @@ -52,21 +52,19 @@ test.describe('Collection Environment Import Tests', () => { // Wait for import to complete and environment settings modal to open await expect(page.locator('.current-environment')).toContainText('Test Collection Environment'); - // The environment settings modal should now be visible with the imported environment - const envSettingsModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' }); - await expect(envSettingsModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); - // Verify imported variables in Test Collection Environment settings - await expect(envSettingsModal.locator('input[name="0.name"]')).toHaveValue('host'); - await expect(envSettingsModal.locator('input[name="1.name"]')).toHaveValue('userId'); - await expect(envSettingsModal.locator('input[name="2.name"]')).toHaveValue('apiKey'); - await expect(envSettingsModal.locator('input[name="3.name"]')).toHaveValue('postTitle'); - await expect(envSettingsModal.locator('input[name="4.name"]')).toHaveValue('postBody'); - await expect(envSettingsModal.locator('input[name="5.name"]')).toHaveValue('secretApiToken'); - await expect(envSettingsModal.locator('input[name="5.secret"]')).toBeChecked(); - await page.getByText('×').click(); + await expect(page.locator('input[name="0.name"]')).toHaveValue('host'); + await expect(page.locator('input[name="1.name"]')).toHaveValue('userId'); + await expect(page.locator('input[name="2.name"]')).toHaveValue('apiKey'); + await expect(page.locator('input[name="3.name"]')).toHaveValue('postTitle'); + await expect(page.locator('input[name="4.name"]')).toHaveValue('postBody'); + await expect(page.locator('input[name="5.name"]')).toHaveValue('secretApiToken'); + await expect(page.locator('input[name="5.secret"]')).toBeChecked(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); - // Test GET request with imported environment await page.locator('.collection-item-name').first().click(); await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}'); await page.locator('[data-testid="send-arrow-icon"]').click(); diff --git a/tests/environments/import-environment/global-env-import.spec.ts b/tests/environments/import-environment/global-env-import.spec.ts index 9b05d1bd6..ab751e5f3 100644 --- a/tests/environments/import-environment/global-env-import.spec.ts +++ b/tests/environments/import-environment/global-env-import.spec.ts @@ -47,21 +47,20 @@ test.describe('Global Environment Import Tests', () => { // Wait for import to complete and global environment settings modal to open await expect(page.locator('.current-environment')).toContainText('Test Global Environment'); - // The global environment settings modal should now be visible with the imported environment - const globalEnvSettingsModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' }); - await expect(globalEnvSettingsModal).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); - // Verify imported variables in Test Global Environment settings - await expect(globalEnvSettingsModal.locator('input[name="0.name"]')).toHaveValue('host'); - await expect(globalEnvSettingsModal.locator('input[name="1.name"]')).toHaveValue('userId'); - await expect(globalEnvSettingsModal.locator('input[name="2.name"]')).toHaveValue('apiKey'); - await expect(globalEnvSettingsModal.locator('input[name="3.name"]')).toHaveValue('postTitle'); - await expect(globalEnvSettingsModal.locator('input[name="4.name"]')).toHaveValue('postBody'); - await expect(globalEnvSettingsModal.locator('input[name="5.name"]')).toHaveValue('secretApiToken'); - await expect(globalEnvSettingsModal.locator('input[name="5.secret"]')).toBeChecked(); - await page.getByText('×').click(); + const variablesTable = page.locator('.table-container'); + await expect(variablesTable.locator('input[name="0.name"]')).toHaveValue('host'); + await expect(variablesTable.locator('input[name="1.name"]')).toHaveValue('userId'); + await expect(variablesTable.locator('input[name="2.name"]')).toHaveValue('apiKey'); + await expect(variablesTable.locator('input[name="3.name"]')).toHaveValue('postTitle'); + await expect(variablesTable.locator('input[name="4.name"]')).toHaveValue('postBody'); + await expect(variablesTable.locator('input[name="5.name"]')).toHaveValue('secretApiToken'); + await expect(variablesTable.locator('input[name="5.secret"]')).toBeChecked(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); - // Test GET request with global environment await page.locator('#collection-environment-test-collection .collection-item-name').first().click(); await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}'); await page.locator('[data-testid="send-arrow-icon"]').click(); diff --git a/tests/environments/multiline-variables/write-multiline-variable.spec.ts b/tests/environments/multiline-variables/write-multiline-variable.spec.ts index d774d88a3..ca4855be1 100644 --- a/tests/environments/multiline-variables/write-multiline-variable.spec.ts +++ b/tests/environments/multiline-variables/write-multiline-variable.spec.ts @@ -11,7 +11,7 @@ test.describe('Multiline Variables - Write Test', () => { // open request await expect(page.getByTitle('multiline-test', { exact: true })).toBeVisible(); - await page.getByTitle('multiline-test', { exact: true }).click(); + await page.getByTitle('multiline-test', { exact: true }).dblclick(); // open environment dropdown await page.locator('div.current-environment').click(); @@ -28,10 +28,12 @@ test.describe('Multiline Variables - Write Test', () => { await expect(page.getByText('Configure', { exact: true })).toBeVisible(); await page.getByText('Configure', { exact: true }).click(); - // add variable - await page.getByRole('button', { name: /Add.*Variable/i }).click(); - const valueTextarea = page.locator('.bruno-modal-card textarea').last(); - await expect(valueTextarea).toBeVisible(); + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); + + const emptyRowNameInput = page.locator('tbody tr').last().locator('input[placeholder="Name"]'); + await expect(emptyRowNameInput).toBeVisible(); + await emptyRowNameInput.fill('multiline_data_json'); const jsonValue = `{ "user": { @@ -48,23 +50,17 @@ test.describe('Multiline Variables - Write Test', () => { } }`; - // fill variable value - await valueTextarea.fill(jsonValue); - await page.keyboard.press('Shift+Tab'); - await page.keyboard.type('multiline_data_json'); + const variableRow = page.locator('tbody tr').filter({ has: page.locator('input[value="multiline_data_json"]') }); + const codeMirror = variableRow.locator('.CodeMirror'); + await codeMirror.click(); + await page.keyboard.insertText(jsonValue); - // save variable and close config - const saveVarButton = page.getByRole('button', { name: /Save/i }); - await expect(saveVarButton).toBeVisible(); - await saveVarButton.click(); + await page.getByTestId('save-env').click(); - await expect(page.locator('.close.cursor-pointer')).toBeVisible(); - await page.locator('.close.cursor-pointer').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); - // send request - const sendButton = page.locator('#send-request').getByRole('img').nth(2); - await expect(sendButton).toBeVisible(); - await sendButton.click(); + await page.getByTestId('send-arrow-icon').click(); // wait for response status await expect(page.locator('.response-status-code.text-ok')).toBeVisible(); diff --git a/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts b/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts index 38d340a88..e57834ec8 100644 --- a/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts +++ b/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts @@ -3,7 +3,6 @@ import { closeAllCollections } from '../../utils/page'; test.describe('Global Environment Variable Update via Script', () => { test.afterEach(async ({ pageWithUserData: page }) => { - // cleanup: close all collections await closeAllCollections(page); }); @@ -23,40 +22,43 @@ test.describe('Global Environment Variable Update via Script', () => { await page.getByTestId('send-arrow-icon').click(); }); - await test.step('Open the Global Environment Config modal', async () => { + await test.step('Open the Global Environment Config tab', async () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByText('Configure', { exact: true }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); - const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' }); - await test.step('Verify that the value of "existingEnvEnabled" is updated by the pre-request script', async () => { - const updatedExistingEnvEnabledInputDiv = await globalEnvModal.getByTestId('env-var-value-1'); - const updatedExistingEnvEnabledValue = await updatedExistingEnvEnabledInputDiv.locator('.CodeMirror-line').textContent(); - await expect(updatedExistingEnvEnabledValue).toContain('newExistingEnvEnabledValue'); + const row = page.locator('tbody tr').filter({ has: page.locator('input[value="existingEnvEnabled"]') }); + const value = await row.locator('.CodeMirror-line').first().textContent(); + await expect(value).toContain('newExistingEnvEnabledValue'); }); await test.step('Verify that the value of "existingEnvDisabled" is updated by the pre-request script', async () => { - const updatedExistingEnvDisabledInputDiv = await globalEnvModal.getByTestId('env-var-value-2'); - const updatedExistingEnvDisabledValue = await updatedExistingEnvDisabledInputDiv.locator('.CodeMirror-line').textContent(); - await expect(updatedExistingEnvDisabledValue).toContain('newExistingEnvDisabledValue'); + const row = page.locator('tbody tr').filter({ has: page.locator('input[value="existingEnvDisabled"]') }); + const value = await row.locator('.CodeMirror-line').first().textContent(); + await expect(value).toContain('newExistingEnvDisabledValue'); }); await test.step('Verify that a new env variable "newEnv" is added by the pre-request script to the global environment', async () => { - const newEnvInputDiv = await globalEnvModal.getByTestId('env-var-value-3'); - const newEnvValue = await newEnvInputDiv.locator('.CodeMirror-line').textContent(); - await expect(newEnvValue).toContain('newEnvValue'); + const row = page.locator('tbody tr').filter({ has: page.locator('input[value="newEnv"]') }); + const value = await row.locator('.CodeMirror-line').first().textContent(); + await expect(value).toContain('newEnvValue'); }); await test.step('Verify that the value of "baseUrl" is unchanged.', async () => { - const currentBaseUrlInputDiv = await globalEnvModal.getByTestId('env-var-value-0'); - const currentBaseUrlValue = await currentBaseUrlInputDiv.locator('.CodeMirror-line').textContent(); - await expect(currentBaseUrlValue).toContain('https://echo.usebruno.com'); + const row = page.locator('tbody tr').filter({ has: page.locator('input[value="baseUrl"]') }); + const value = await row.locator('.CodeMirror-line').first().textContent(); + await expect(value).toContain('https://echo.usebruno.com'); }); - await test.step('Close the global environment config modal.', async () => { - await page.getByTestId('modal-close-button').click(); + await test.step('Close the global environment config tab.', async () => { + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); }); diff --git a/tests/global-environments/non-string-values.spec.ts b/tests/global-environments/non-string-values.spec.ts index 4fe5e8774..395370ca2 100644 --- a/tests/global-environments/non-string-values.spec.ts +++ b/tests/global-environments/non-string-values.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../playwright'; -import { openCollectionAndAcceptSandbox, closeAllCollections, sendRequest } from '../utils/page'; +import { openCollectionAndAcceptSandbox, closeAllCollections, sendRequest, addEnvironmentVariables } from '../utils/page'; import { buildCommonLocators } from '../utils/page/locators'; test.describe('Global Environment Variables - Non-string Values', () => { @@ -23,20 +23,19 @@ test.describe('Global Environment Variables - Non-string Values', () => { await page.locator('#environment-name').fill('Test Env'); await page.getByRole('button', { name: 'Create', exact: true }).click(); - // Add a string variable. - await page.getByTestId('add-variable').click(); - const newRow = page.locator('tbody tr').last(); - await newRow.locator('input[name$=".name"]').fill('stringVar'); - await newRow.locator('.CodeMirror').click(); - await page.keyboard.type('hello world'); + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); + + await addEnvironmentVariables(page, [ + { name: 'stringVar', value: 'hello world' }, + { name: 'numericVar', value: '170001' }, + { name: 'booleanVar', value: 'true' } + ]); - // Save await page.getByTestId('save-env').click(); - // Verify that the string variable value is saved and displayed correctly. - await expect(newRow.locator('.CodeMirror-line').first()).toContainText('hello world'); - // Close the environment modal - await page.locator('[data-test-id="modal-close-button"]').click(); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); // Request contains a script that sets the non-string global variables. @@ -50,14 +49,13 @@ test.describe('Global Environment Variables - Non-string Values', () => { await page.getByTestId('environment-selector-trigger').click(); await page.getByTestId('env-tab-global').click(); await page.getByRole('button', { name: 'Configure' }).click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); }); - const envModal = page - .locator('.bruno-modal-card') - .filter({ has: page.locator('.bruno-modal-header-title', { hasText: 'Global Environments' }) }); - - const numericInput = envModal.locator('input[value="numericVar"]'); - const booleanInput = envModal.locator('input[value="booleanVar"]'); + const numericInput = page.locator('input[value="numericVar"]'); + const booleanInput = page.locator('input[value="booleanVar"]'); await expect(numericInput).toBeVisible(); await expect(booleanInput).toBeVisible(); const numericRow = numericInput.locator('xpath=ancestor::tr'); @@ -74,9 +72,7 @@ test.describe('Global Environment Variables - Non-string Values', () => { await page.keyboard.type('999'); await expect(numericRow.locator('.CodeMirror-line').first()).toContainText(/170001/); - // Hovering over the info icon reveals the tooltip. - // It is anchored to the info icon element id, so hover/click reveals it reliably. - const infoIcon = page.locator('#numericVar-disabled-info-icon'); + const infoIcon = numericRow.locator('[id$="-disabled-info-icon"]').nth(0); await infoIcon.hover(); // The tooltip explains why the field is locked. @@ -103,8 +99,7 @@ test.describe('Global Environment Variables - Non-string Values', () => { await page.keyboard.type('false'); await expect(booleanRow.locator('.CodeMirror-line').first()).toContainText(/true/); - // Hovering over the info icon reveals the tooltip. - const infoIcon = page.locator('#booleanVar-disabled-info-icon'); + const infoIcon = booleanRow.locator('[id$="-disabled-info-icon"]').nth(0); await infoIcon.hover(); // The tooltip explains why the field is locked. @@ -114,8 +109,7 @@ test.describe('Global Environment Variables - Non-string Values', () => { }); await test.step('Verify that stringVar remains editable', async () => { - // Unlike script-managed values above, this one is user-managed. - const stringInput = envModal.locator('input[value="stringVar"]'); + const stringInput = page.locator('input[value="stringVar"]'); await expect(stringInput).toBeVisible(); const stringRow = stringInput.locator('xpath=ancestor::tr'); @@ -125,8 +119,12 @@ test.describe('Global Environment Variables - Non-string Values', () => { // Verify the user edit persists in the UI. await expect(stringRow.locator('.CodeMirror-line').first()).toContainText('hello world updated'); - // Close the environment modal - await page.locator('[data-test-id="modal-close-button"]').click(); + + await page.getByTestId('save-env').click(); + + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); }); diff --git a/tests/import/insomnia/import-insomnia-v4-environments.spec.ts b/tests/import/insomnia/import-insomnia-v4-environments.spec.ts index 4eaabdd69..9b1b9bdef 100644 --- a/tests/import/insomnia/import-insomnia-v4-environments.spec.ts +++ b/tests/import/insomnia/import-insomnia-v4-environments.spec.ts @@ -178,9 +178,10 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => { await expect(page.getByTestId('env-var-row-newFeature.version').locator('.CodeMirror-line').first()).toHaveText('2.099123123'); }); - await test.step('Close environment modal', async () => { - // Close the environment configuration modal to ensure clean state - await page.getByText('×').click(); + await test.step('Close environment tab', async () => { + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); }); diff --git a/tests/import/insomnia/import-insomnia-v5-environments.spec.ts b/tests/import/insomnia/import-insomnia-v5-environments.spec.ts index 25555e2a8..11fca239e 100644 --- a/tests/import/insomnia/import-insomnia-v5-environments.spec.ts +++ b/tests/import/insomnia/import-insomnia-v5-environments.spec.ts @@ -202,9 +202,10 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => { await expect(page.getByTestId('env-var-row-user.roles[0]').locator('.CodeMirror-line').first()).toHaveText('admin'); }); - await test.step('Close environment modal', async () => { - // Close the environment configuration modal to ensure clean state - await page.getByText('×').click(); + await test.step('Close environment tab', async () => { + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }); }); diff --git a/tests/request/newlines/newlines-persistence.spec.ts b/tests/request/newlines/newlines-persistence.spec.ts index 775871840..2d019ee01 100644 --- a/tests/request/newlines/newlines-persistence.spec.ts +++ b/tests/request/newlines/newlines-persistence.spec.ts @@ -16,6 +16,8 @@ test('should persist request with newlines across app restarts', async ({ create await page.locator('.bruno-modal').getByLabel('Location').fill(collectionPath); await page.locator('.bruno-modal').getByRole('button', { name: 'Create' }).click(); + await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe'); + const collection = page.getByTestId('collections').locator('.collection-name').filter({ hasText: 'newlines-persistence' }); await collection.hover(); await collection.locator('.collection-actions .icon').click(); @@ -25,7 +27,6 @@ test('should persist request with newlines across app restarts', async ({ create await page.locator('#new-request-url').locator('textarea').fill('https://httpbin.org/get'); await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click(); - await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe'); await page.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick(); await page.getByRole('tab', { name: 'Params' }).click(); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index a4da13a8c..611dd2c29 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -390,10 +390,23 @@ const createEnvironment = async ( await page.locator('button[id="create-env"]').click(); - const nameInput = page.locator('input[name="name"]'); + const nameInput = type === 'collection' + ? page.locator('input[name="name"]') + : page.locator('#environment-name'); await expect(nameInput).toBeVisible(); await nameInput.fill(environmentName); await page.getByRole('button', { name: 'Create' }).click(); + + const tabLabel = type === 'collection' ? 'Environments' : 'Global Environments'; + await expect(page.locator('.request-tab').filter({ hasText: tabLabel })).toBeVisible(); + + const locators = buildCommonLocators(page); + await locators.environment.selector().click(); + if (type === 'global') { + await locators.environment.globalTab().click(); + } + await locators.environment.envOption(environmentName).click(); + await expect(page.locator('.current-environment')).toContainText(environmentName); }); }; @@ -416,11 +429,6 @@ const addEnvironmentVariable = async ( index: number ) => { await test.step(`Add environment variable "${variable.name}"`, async () => { - const addButton = page.locator('button[data-testid="add-variable"]'); - await addButton.waitFor({ state: 'visible' }); - await addButton.click(); - - // Wait for the new row to be added and the name input to be visible const nameInput = page.locator(`input[name="${index}.name"]`); await nameInput.waitFor({ state: 'visible' }); await nameInput.fill(variable.name); @@ -466,13 +474,17 @@ const saveEnvironment = async (page: Page) => { }; /** - * Close the environment modal/panel + * Close the environment tab * @param page - The page object + * @param type - The type of environment tab to close * @returns void */ -const closeEnvironmentPanel = async (page: Page) => { - await test.step('Close environment panel', async () => { - await page.getByText('×').click(); +const closeEnvironmentPanel = async (page: Page, type: EnvironmentType = 'collection') => { + await test.step('Close environment tab', async () => { + const tabLabel = type === 'collection' ? 'Environments' : 'Global Environments'; + const envTab = page.locator('.request-tab').filter({ hasText: tabLabel }); + await envTab.hover(); + await envTab.getByTestId('request-tab-close-icon').click(); }); }; diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 67efa05ee..f822b1c89 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -39,7 +39,7 @@ export const buildCommonLocators = (page: Page) => ({ tabs: { requestTab: (requestName: string) => page.locator('.request-tab .tab-label').filter({ hasText: requestName }), activeRequestTab: () => page.locator('.request-tab.active'), - closeTab: (requestName: string) => page.locator('.request-tab').filter({ hasText: requestName }).locator('.close-icon') + closeTab: (requestName: string) => page.locator('.request-tab').filter({ hasText: requestName }).getByTestId('request-tab-close-icon') }, folder: { chevron: (folderName: string) => page.locator('.collection-item-name').filter({ hasText: folderName }).getByTestId('folder-chevron')