From d6e17e1dabb6a58b15f4a22e0ffd3096bd114657 Mon Sep 17 00:00:00 2001 From: Pooja Date: Fri, 26 Jun 2026 12:21:58 +0530 Subject: [PATCH] feat(environments): split variables and secrets into separate tabs (#8191) --- .../EnvironmentVariablesTable/index.js | 265 +++++++++--- .../DeleteEnvironment/index.js | 4 +- .../EnvironmentVariables/index.js | 5 +- .../EnvironmentDetails/StyledWrapper.js | 36 +- .../EnvironmentDetails/index.js | 117 ++++-- .../EnvironmentList/StyledWrapper.js | 33 +- .../Environments/EnvironmentSettings/index.js | 25 +- .../RequestTab/GradientCloseButton/index.js | 2 +- .../RequestTabs/RequestTab/index.js | 8 +- .../DeleteEnvironment/index.js | 4 +- .../EnvironmentVariables/index.js | 3 +- .../EnvironmentDetails/StyledWrapper.js | 38 +- .../EnvironmentDetails/index.js | 115 +++-- .../EnvironmentList/StyledWrapper.js | 21 +- .../WorkspaceEnvironments/index.js | 25 +- .../ReduxStore/slices/collections/index.js | 7 +- .../src/providers/ReduxStore/slices/tabs.js | 8 + .../src/ui/MenuDropdown/SubMenuItem/index.js | 12 +- .../bruno-app/src/ui/MenuDropdown/index.js | 3 + .../api-setEnvVar-with-persist-typed.spec.ts | 2 + .../global-env-create.spec.ts | 12 +- .../datatype-preservation.spec.ts | 19 +- .../collection-env-import.spec.ts | 4 + .../global-env-import.spec.ts | 4 + .../collection-env-import.spec.ts | 6 +- .../global-env-import.spec.ts | 6 +- .../save-shortcut/save-shortcut.spec.ts | 76 ++++ .../variable-secret-tabs.spec.ts | 397 ++++++++++++++++++ tests/utils/page/actions.ts | 101 +++-- tests/utils/page/locators.ts | 7 +- .../variable-datatypes/create-via-ui.spec.ts | 45 +- .../parsed-from-fixture.spec.ts | 26 +- 32 files changed, 1129 insertions(+), 307 deletions(-) create mode 100644 tests/environments/save-shortcut/save-shortcut.spec.ts create mode 100644 tests/environments/variable-secret-tabs/variable-secret-tabs.spec.ts diff --git a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js index 440fd4835..c07dbf671 100644 --- a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js +++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js @@ -1,7 +1,8 @@ import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react'; import { TableVirtuoso } from 'react-virtuoso'; import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash, IconAlertCircle } from '@tabler/icons'; +import isEqual from 'lodash/isEqual'; +import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons'; import { useTheme } from 'providers/Theme'; import { useSelector, useDispatch } from 'react-redux'; import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; @@ -24,6 +25,15 @@ const MIN_H = 35 * 2; const MIN_COLUMN_WIDTH = 80; const MIN_ROW_HEIGHT = 35; +// Non-secret rows first, then secrets. The tabs save independently, so a stable +// order keeps the "modified" comparison accurate regardless of which tab saved last. +const orderVarsBySecret = (vars) => { + const nonSecret = []; + const secret = []; + vars.forEach((v) => (v.secret ? secret : nonSecret).push(v)); + return [...nonSecret, ...secret]; +}; + const TableRow = React.memo( ({ children, item, style, ...rest }) => { const variable = item?.variable ?? item; @@ -49,8 +59,10 @@ const EnvironmentVariablesTable = ({ onDraftClear, setIsModified, renderExtraValueContent, - searchQuery = '' + searchQuery = '', + variableType = 'variables' }) => { + const isSecretTab = variableType === 'secrets'; const { storedTheme } = useTheme(); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); const activeWorkspace = useSelector((state) => { @@ -67,7 +79,6 @@ const EnvironmentVariablesTable = ({ const rowCount = (environment.variables?.length || 0) + 1; const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT); - // We need to add component for env table const [scroll, setScroll] = usePersistedState({ key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`, default: 0 @@ -166,15 +177,19 @@ const EnvironmentVariablesTable = ({ const _collection = useMemo(() => { const c = collection ? cloneDeep(collection) : {}; c.globalEnvironmentVariables = globalEnvironmentVariables; + c.activeEnvironmentUid = environment.uid; if (!collection && workspaceProcessEnvVariables) { c.workspaceProcessEnvVariables = workspaceProcessEnvVariables; } return c; - }, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables]); + }, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables, environment.uid]); + // Reuse the previous initialValues when only uids changed but the content is + // identical. + const initialValuesRef = useRef(null); const initialValues = useMemo(() => { const vars = environment.variables || []; - return [ + const next = [ ...vars, { uid: uuid(), @@ -185,6 +200,12 @@ const EnvironmentVariablesTable = ({ enabled: true } ]; + const prev = initialValuesRef.current; + if (prev && isEqual(prev.map(stripEnvVarUid), next.map(stripEnvVarUid))) { + return prev; + } + initialValuesRef.current = next; + return next; }, [environment.uid, environment.variables]); const formik = useFormik({ @@ -255,7 +276,7 @@ const EnvironmentVariablesTable = ({ name: '', value: '', type: 'text', - secret: false, + secret: isSecretTab, enabled: true } ]); @@ -270,6 +291,18 @@ const EnvironmentVariablesTable = ({ setPinnedData({ query: '', uids: new Set() }); }, [savedValuesJson]); + // Keep the trailing empty "add new" row's secret flag in sync with the active + // tab, so typing into it creates a variable of the correct type. The empty row + // is filtered out of save/draft, so this never affects persisted data. + useEffect(() => { + const lastIndex = formik.values.length - 1; + const last = formik.values[lastIndex]; + const isEmpty = !last?.name || (typeof last.name === 'string' && last.name.trim() === ''); + if (last && isEmpty && !!last.secret !== isSecretTab) { + formik.setFieldValue(`${lastIndex}.secret`, isSecretTab, false); + } + }, [isSecretTab, formik.values]); + // Sync modified state useEffect(() => { const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); @@ -354,7 +387,7 @@ const EnvironmentVariablesTable = ({ name: '', value: '', type: 'text', - secret: false, + secret: isSecretTab, enabled: true } ]; @@ -369,12 +402,16 @@ const EnvironmentVariablesTable = ({ const isLastRow = index === formik.values.length - 1; if (isLastRow) { + // Pin the newly-named row's secret flag to the active tab synchronously; the + // passive sync effect runs after paint and is racy for fast input. + formik.setFieldValue(`${index}.secret`, isSecretTab, false); + const newVariable = { uid: uuid(), name: '', value: '', type: 'text', - secret: false, + secret: isSecretTab, enabled: true }; setTimeout(() => { @@ -395,9 +432,19 @@ const EnvironmentVariablesTable = ({ }; const handleSave = useCallback(() => { - const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const belongsToActiveTab = (variable) => (isSecretTab ? !!variable.secret : !variable.secret); + + const namedValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); const savedValues = environment.variables || []; + // Save is scoped to the active tab. Only the active tab's rows are persisted; the + // other tab keeps its last-saved rows so saving variables never touches secrets and + // vice versa. + const activeCurrent = namedValues.filter(belongsToActiveTab); + const activeSaved = savedValues.filter(belongsToActiveTab); + const otherCurrent = namedValues.filter((variable) => !belongsToActiveTab(variable)); + const otherSaved = savedValues.filter((variable) => !belongsToActiveTab(variable)); + // Compare against what's on disk: for an ephemeral overlay, that's // `persistedValue`, not the scripted value Redux is holding. const baselineForCompare = (v) => { @@ -407,13 +454,15 @@ const EnvironmentVariablesTable = ({ } return stripped; }; - const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(baselineForCompare)); + // Compare without UIDs; only the active tab's subset decides if there's anything to save. + const hasChanges + = JSON.stringify(activeCurrent.map(stripEnvVarUid)) !== JSON.stringify(activeSaved.map(baselineForCompare)); if (!hasChanges) { toast.error('No changes to save'); return; } - const hasValidationErrors = variablesToSave.some((variable) => { + const hasValidationErrors = activeCurrent.some((variable) => { if (!variable.name || variable.name.trim() === '') { return true; } @@ -428,72 +477,182 @@ const EnvironmentVariablesTable = ({ return; } - onSave(cloneDeep(variablesToSave)) + // Persist the active tab's edits alongside the other tab's last-saved rows (unchanged). + const persistedVariables = orderVarsBySecret([...activeCurrent, ...otherSaved]); + + onSave(cloneDeep(persistedVariables)) + .then(() => { + toast.success('Changes saved successfully'); + + // Preserve unsaved edits on the other tab across the post-save reinit via the + // draft: keep it if the other tab is still dirty, clear it otherwise. + const otherDirty + = JSON.stringify(otherCurrent.map(stripEnvVarUid)) !== JSON.stringify(otherSaved.map(stripEnvVarUid)); + const retainedVariables = orderVarsBySecret([...activeCurrent, ...otherCurrent]); + + if (otherDirty) { + onDraftChange(cloneDeep(retainedVariables)); + } else { + onDraftClear(); + } + + formik.resetForm({ + values: [ + ...retainedVariables, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: isSecretTab, + enabled: true + } + ] + }); + setIsModified(otherDirty); + }) + .catch((error) => { + console.error(error); + toast.error('An error occurred while saving the changes'); + }); + }, [formik.values, environment.variables, onSave, onDraftChange, onDraftClear, setIsModified, isSecretTab]); + + const handleReset = useCallback(() => { + const belongsToActiveTab = (variable) => (isSecretTab ? !!variable.secret : !variable.secret); + + const savedValues = environment.variables || []; + const activeSaved = savedValues.filter(belongsToActiveTab); + const otherSaved = savedValues.filter((variable) => !belongsToActiveTab(variable)); + const otherCurrent = formik.values + .filter((variable) => variable.name && variable.name.trim() !== '') + .filter((variable) => !belongsToActiveTab(variable)); + + // Reset is scoped to the active tab: revert its rows to the saved baseline while + // leaving the other tab's current (possibly unsaved) edits intact. + const resetVariables = orderVarsBySecret([...activeSaved, ...otherCurrent]); + + const otherDirty + = JSON.stringify(otherCurrent.map(stripEnvVarUid)) !== JSON.stringify(otherSaved.map(stripEnvVarUid)); + + if (otherDirty) { + onDraftChange(cloneDeep(resetVariables)); + } else { + onDraftClear(); + } + + formik.resetForm({ + values: [ + ...resetVariables, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: isSecretTab, + enabled: true + } + ] + }); + setIsModified(otherDirty); + }, [environment.variables, formik.values, isSecretTab, onDraftChange, onDraftClear, setIsModified]); + + const handleSaveAll = useCallback(() => { + const namedValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const savedValues = environment.variables || []; + + const persistedVariables = orderVarsBySecret(namedValues); + + const hasChanges + = JSON.stringify(persistedVariables.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid)); + if (!hasChanges) { + toast.error('No changes to save'); + return; + } + + const hasValidationErrors = namedValues.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; + } + + onSave(cloneDeep(persistedVariables)) .then(() => { toast.success('Changes saved successfully'); onDraftClear(); - const newValues = [ - ...variablesToSave, - { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - } - ]; - formik.resetForm({ values: newValues }); + + formik.resetForm({ + values: [ + ...persistedVariables, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: isSecretTab, + enabled: true + } + ] + }); setIsModified(false); }) .catch((error) => { console.error(error); toast.error('An error occurred while saving the changes'); }); - }, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]); - - const handleReset = useCallback(() => { - const originalVars = environment.variables || []; - const resetValues = [ - ...originalVars, - { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - } - ]; - formik.resetForm({ values: resetValues }); - setIsModified(false); - }, [environment.variables, setIsModified]); + }, [formik.values, environment.variables, onSave, onDraftClear, setIsModified, isSecretTab]); const handleSaveRef = useRef(handleSave); handleSaveRef.current = handleSave; + const handleSaveAllRef = useRef(handleSaveAll); + handleSaveAllRef.current = handleSaveAll; useEffect(() => { const handleSaveEvent = () => { handleSaveRef.current(); }; + const handleSaveAllEvent = () => { + handleSaveAllRef.current(); + }; window.addEventListener('environment-save', handleSaveEvent); + window.addEventListener('environment-save-all', handleSaveAllEvent); return () => { window.removeEventListener('environment-save', handleSaveEvent); + window.removeEventListener('environment-save-all', handleSaveAllEvent); }; }, []); const filteredVariables = useMemo(() => { - const allVariables = formik.values.map((variable, index) => ({ variable, index })); + const lastIndex = formik.values.length - 1; + // Show only rows belonging to the active tab, but always keep the trailing + // empty "add new" row so the user can add a variable/secret on either tab. + const tabVariables = formik.values + .map((variable, index) => ({ variable, index })) + .filter(({ variable, index }) => { + const isLastEmptyRow + = index === lastIndex && (!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '')); + if (isLastEmptyRow) return true; + return isSecretTab ? !!variable.secret : !variable.secret; + }); + if (!searchQuery?.trim()) { - return allVariables; + return tabVariables; } const query = searchQuery.toLowerCase().trim(); const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set(); - return allVariables.filter(({ variable }) => { + return tabVariables.filter(({ variable }) => { if (effectivePins.has(variable.uid)) return true; const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false; const valueText @@ -505,7 +664,7 @@ const EnvironmentVariablesTable = ({ const valueMatch = valueText.toLowerCase().includes(query); return !!(nameMatch || valueMatch); }); - }, [formik.values, searchQuery, pinnedData]); + }, [formik.values, searchQuery, pinnedData, isSecretTab]); const isSearchActive = !!searchQuery?.trim(); @@ -535,7 +694,6 @@ const EnvironmentVariablesTable = ({ /> Value - Secret )} @@ -614,7 +772,7 @@ const EnvironmentVariablesTable = ({ name: '', value: '', type: 'text', - secret: false, + secret: isSecretTab, enabled: true }, false); }, 0); @@ -639,17 +797,6 @@ const EnvironmentVariablesTable = ({ )} {renderExtraValueContent && renderExtraValueContent(variable)} - - {!isLastEmptyRow && ( - - )} - {!isLastEmptyRow && ( + + + + setOpenCopyModal(true)} data-testid="env-copy-action"> + + + setOpenDeleteModal(true)} colorOnHover="danger" data-testid="env-delete-action"> + + + + + +
+ + + + + {isSearchExpanded ? ( +
+ + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="search-input" + data-testid="env-search-input" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + {searchQuery && ( + + )} +
+ ) : ( + + + )}
- ) : ( - )} - - - - + rightContentRef={rightContentRef} + />
@@ -237,6 +271,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer setIsModified={setIsModified} collection={collection} searchQuery={debouncedSearchQuery} + variableType={activeTab} />
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 b2f6aad5c..26af3edd8 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -45,7 +45,7 @@ const StyledWrapper = styled.div` 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}; @@ -79,7 +79,7 @@ const StyledWrapper = styled.div` &::placeholder { color: ${(props) => props.theme.colors.text.muted}; } - + &:focus { outline: none; border-color: ${(props) => props.theme.colors.accent}; @@ -111,6 +111,7 @@ const StyledWrapper = styled.div` flex-direction: column; overflow: hidden; padding: 8px; + border-right: 1px solid ${(props) => props.theme.border.border0}; } .section-header { @@ -163,7 +164,7 @@ const StyledWrapper = styled.div` cursor: pointer; border-radius: 6px; transition: background 0.15s ease; - + .environment-name { flex: 1; white-space: nowrap; @@ -216,18 +217,18 @@ const StyledWrapper = styled.div` &:hover { background: ${(props) => props.theme.workspace.button.bg}; } - + &.active { background: ${(props) => props.theme.background.surface0}; 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}; } @@ -239,7 +240,7 @@ const StyledWrapper = styled.div` flex: 1; min-width: 0; overflow: hidden; - + .environment-name-input { flex: 1; min-width: 0; @@ -249,12 +250,12 @@ const StyledWrapper = styled.div` 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; @@ -273,12 +274,12 @@ const StyledWrapper = styled.div` 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; @@ -299,25 +300,25 @@ const StyledWrapper = styled.div` border-radius: 4px; cursor: pointer; transition: all 0.15s ease; - + &.save { color: ${(props) => props.theme.colors.text.green}; - + &:hover { background: ${(props) => rgba(props.theme.colors.text.green, 0.1)}; } } - + &.cancel { color: ${(props) => props.theme.colors.text.danger}; - + &:hover { background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)}; } } } } - + .env-error { padding: 4px 12px; margin-top: 4px; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js index 8a7e43ca1..74508ccb6 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js @@ -1,16 +1,33 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateTabState } from 'providers/ReduxStore/slices/tabs'; import EnvironmentList from './EnvironmentList'; import StyledWrapper from './StyledWrapper'; import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal'; const EnvironmentSettings = ({ collection }) => { + const dispatch = useDispatch(); const [isModified, setIsModified] = useState(false); const environments = collection?.environments || []; - const [selectedEnvironment, setSelectedEnvironment] = useState(() => { + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const persistedEnvUid = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envUid); + + // Remember which environment the user last viewed in this tab (via tabState) so navigating away and back preserves it. + const selectedEnvironment = useMemo(() => { if (!environments.length) return null; - return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0]; - }); + return ( + environments.find((env) => env.uid === persistedEnvUid) + || environments.find((env) => env.uid === collection?.activeEnvironmentUid) + || environments[0] + ); + }, [environments, persistedEnvUid, collection?.activeEnvironmentUid]); + + const setSelectedEnvironment = (env) => { + if (!activeTabUid || !env?.uid) return; + dispatch(updateTabState({ uid: activeTabUid, tabState: { envUid: env.uid } })); + }; + const [showExportModal, setShowExportModal] = useState(false); return ( diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/GradientCloseButton/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/GradientCloseButton/index.js index e34e3f5d0..c5a61b33f 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/GradientCloseButton/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/GradientCloseButton/index.js @@ -7,7 +7,7 @@ const GradientCloseButton = ({ onClick, hasChanges = false }) => { return (
- + diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 15df5e719..f3619f49a 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -256,7 +256,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi if (environmentUid?.startsWith('dotenv:')) { window.dispatchEvent(new Event('dotenv-save')); } else { - dispatch(saveEnvironment(variables, environmentUid, collection.uid)); + dispatch(saveEnvironment(variables, environmentUid, collection.uid)) + .then(() => toast.success('Changes saved successfully')) + .catch(() => toast.error('An error occurred while saving the changes')); } } } else if (tab.type === 'global-environment-settings') { @@ -265,7 +267,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi if (environmentUid?.startsWith('dotenv:')) { window.dispatchEvent(new Event('dotenv-save')); } else { - dispatch(saveGlobalEnvironment({ variables, environmentUid })); + dispatch(saveGlobalEnvironment({ variables, environmentUid })) + .then(() => toast.success('Changes saved successfully')) + .catch(() => toast.error('An error occurred while saving the changes')); } } } else if (tab.type === 'folder-settings') { diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/index.js index 766215d66..d5c8966b0 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/index.js @@ -21,13 +21,13 @@ const DeleteEnvironment = ({ onClose, environment }) => { - Are you sure you want to delete {environment.name} ? + Are you sure you want to delete {environment.name}? 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 369377d30..b557f5719 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 @@ -9,7 +9,7 @@ import { } from 'providers/ReduxStore/slices/global-environments'; import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable'; -const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => { +const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '', variableType = 'variables' }) => { const dispatch = useDispatch(); const { globalEnvironmentDraft } = useSelector((state) => state.globalEnvironments); @@ -49,6 +49,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu onDraftClear={handleDraftClear} setIsModified={setIsModified} searchQuery={searchQuery} + variableType={variableType} /> ); }; 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 dd7e6fa1b..47540bf00 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 @@ -96,6 +96,17 @@ const StyledWrapper = styled.div` display: flex; align-items: center; gap: 2px; + } + } + + .tabs-container { + padding: 0 20px; + flex-shrink: 0; + + .env-search-container { + display: flex; + align-items: center; + gap: 2px; .search-input-wrapper { position: relative; @@ -150,39 +161,16 @@ const StyledWrapper = styled.div` } } } - - 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; + margin-top: 16px; } `; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js index ff3d98dac..c3afdc62a 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js @@ -1,15 +1,23 @@ -import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons'; +import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconDeviceFloppy } from '@tabler/icons'; import { useState, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments'; +import { updateTabState } from 'providers/ReduxStore/slices/tabs'; import { validateName, validateNameError } from 'utils/common/regex'; import toast from 'react-hot-toast'; import CopyEnvironment from '../../CopyEnvironment'; import DeleteEnvironment from '../../DeleteEnvironment'; import EnvironmentVariables from './EnvironmentVariables'; import ColorPicker from 'components/ColorPicker'; +import ActionIcon from 'ui/ActionIcon'; +import ResponsiveTabs from 'ui/ResponsiveTabs'; import StyledWrapper from './StyledWrapper'; +const TABS = [ + { key: 'variables', label: 'Variables' }, + { key: 'secrets', label: 'Secrets' } +]; + const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => { const dispatch = useDispatch(); const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments); @@ -19,7 +27,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer const [isRenaming, setIsRenaming] = useState(false); const [newName, setNewName] = useState(''); const [nameError, setNameError] = useState(''); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const activeTab = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envTab) || 'variables'; + const setActiveTab = (tab) => dispatch(updateTabState({ uid: activeTabUid, tabState: { envTab: tab } })); const inputRef = useRef(null); + const rightContentRef = useRef(null); const validateEnvironmentName = (name) => { if (!name || name.trim() === '') { @@ -132,6 +144,10 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer dispatch(updateGlobalEnvironmentColor(environment.uid, color)); }; + const handleSaveAll = () => { + window.dispatchEvent(new Event('environment-save-all')); + }; + return ( {openDeleteModal && ( @@ -189,48 +205,66 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
{nameError && isRenaming &&
{nameError}
}
- {isSearchExpanded ? ( -
- - setSearchQuery(e.target.value)} - onBlur={handleSearchBlur} - className="search-input" - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - /> - {searchQuery && ( - + + + + setOpenCopyModal(true)} data-testid="env-copy-action"> + + + setOpenDeleteModal(true)} colorOnHover="danger" data-testid="env-delete-action"> + + +
+
+ +
+ + + + + {isSearchExpanded ? ( +
+ + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="search-input" + data-testid="env-search-input" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + {searchQuery && ( + + )} +
+ ) : ( + + + )}
- ) : ( - )} - - - - + rightContentRef={rightContentRef} + />
@@ -239,6 +273,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer setIsModified={setIsModified} collection={collection} searchQuery={debouncedSearchQuery} + variableType={activeTab} />
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 c436d53d1..ab556ab45 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js @@ -46,7 +46,7 @@ const StyledWrapper = styled.div` 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}; @@ -80,7 +80,7 @@ const StyledWrapper = styled.div` &::placeholder { color: ${(props) => props.theme.colors.text.muted}; } - + &:focus { outline: none; border-color: ${(props) => props.theme.colors.accent}; @@ -112,6 +112,7 @@ const StyledWrapper = styled.div` flex-direction: column; overflow: hidden; padding: 8px; + border-right: 1px solid ${(props) => props.theme.border.border0}; } .section-header { @@ -164,7 +165,7 @@ const StyledWrapper = styled.div` cursor: pointer; border-radius: 6px; transition: background 0.15s ease; - + .environment-name { flex: 1; white-space: nowrap; @@ -217,18 +218,18 @@ const StyledWrapper = styled.div` &:hover { background: ${(props) => props.theme.workspace.button.bg}; } - + &.active { background: ${(props) => props.theme.background.surface0}; 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}; } @@ -240,7 +241,7 @@ const StyledWrapper = styled.div` flex: 1; min-width: 0; overflow: hidden; - + .environment-name-input { flex: 1; min-width: 0; @@ -250,12 +251,12 @@ const StyledWrapper = styled.div` 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; @@ -318,7 +319,7 @@ const StyledWrapper = styled.div` } } } - + .env-error { padding: 4px 12px; margin-top: 4px; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js index 264c1fb65..f466d48ad 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js @@ -1,21 +1,36 @@ -import React, { useState } from 'react'; -import { useSelector } from 'react-redux'; +import React, { useState, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateTabState } from 'providers/ReduxStore/slices/tabs'; import EnvironmentList from './EnvironmentList'; import StyledWrapper from './StyledWrapper'; import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal'; const WorkspaceEnvironments = ({ workspace }) => { + const dispatch = useDispatch(); const [isModified, setIsModified] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments); const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid); - const [selectedEnvironment, setSelectedEnvironment] = useState(() => { + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const persistedEnvUid = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envUid); + + // Remember which environment the user last viewed in this tab (via tabState) so navigating away and back preserves it. + const selectedEnvironment = useMemo(() => { const environments = globalEnvironments || []; if (!environments.length) return null; - return environments.find((env) => env.uid === activeGlobalEnvironmentUid) || environments[0]; - }); + return ( + environments.find((env) => env.uid === persistedEnvUid) + || environments.find((env) => env.uid === activeGlobalEnvironmentUid) + || environments[0] + ); + }, [globalEnvironments, persistedEnvUid, activeGlobalEnvironmentUid]); + + const setSelectedEnvironment = (env) => { + if (!activeTabUid || !env?.uid) return; + dispatch(updateTabState({ uid: activeTabUid, tabState: { envUid: env.uid } })); + }; return ( 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 a65dbdb54..05b97effa 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -904,7 +904,12 @@ export const collectionsSlice = createSlice({ const { collectionUid, environmentUid, variables } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (collection) { - collection.environmentsDraft = { environmentUid, variables }; + // Reset the draft when switching environments; otherwise update variables in place. + if (!collection.environmentsDraft || collection.environmentsDraft.environmentUid !== environmentUid) { + collection.environmentsDraft = { environmentUid, variables }; + } else { + collection.environmentsDraft.variables = variables; + } } }, clearEnvironmentsDraft: (state, action) => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index c6965e6be..95eacbbf3 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -500,6 +500,13 @@ export const tabsSlice = createSlice({ state.activeTabUid = state.tabs.find((t) => t.collectionUid === collectionUid)?.uid || null; } }, + updateTabState: (state, action) => { + const { uid, tabState } = action.payload; + const tab = find(state.tabs, (t) => t.uid === uid); + if (tab) { + tab.tabState = { ...tab.tabState, ...tabState }; + } + }, reopenLastClosedTab: (state, action) => { const collectionUid = action.payload?.collectionUid; // Find the last closed tab for this collection (LIFO). If no collectionUid is @@ -550,6 +557,7 @@ export const { reorderTabs, syncTabUid, restoreTabs, + updateTabState, reopenLastClosedTab, updateQueryBuilderOpen, updateQueryBuilderWidth, diff --git a/packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js b/packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js index f2b5bf476..5a6d915bc 100644 --- a/packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js +++ b/packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js @@ -3,6 +3,8 @@ import { IconChevronRight, IconChevronLeft } from '@tabler/icons'; const SubMenuItem = ({ item, + selectedItemId, + showTickMark, onRootClose, submenuPlacement, getMenuItemProps, @@ -25,11 +27,14 @@ const SubMenuItem = ({ }; }); + const hasSelectedChild = selectedItemId != null + && item.submenu.some((subItem) => subItem.id === selectedItemId); + const itemProps = getMenuItemProps(item, { - 'className': 'has-submenu', + 'className': `has-submenu ${hasSelectedChild ? 'dropdown-item-active' : ''}`, 'aria-haspopup': 'true', 'aria-expanded': submenuOpen, - 'aria-current': undefined // submenu triggers don't need aria-current + 'aria-current': hasSelectedChild ? 'true' : undefined }); const arrowElement = ( @@ -49,7 +54,8 @@ const SubMenuItem = ({ placement={submenuTippyPlacement} opened={submenuOpen} onChange={setSubmenuOpen} - showTickMark={false} + selectedItemId={selectedItemId} + showTickMark={showTickMark} submenuPlacement={submenuPlacement} appendTo={() => document.body} offset={[0, 0]} diff --git a/packages/bruno-app/src/ui/MenuDropdown/index.js b/packages/bruno-app/src/ui/MenuDropdown/index.js index 92865d61b..bea065d26 100644 --- a/packages/bruno-app/src/ui/MenuDropdown/index.js +++ b/packages/bruno-app/src/ui/MenuDropdown/index.js @@ -156,6 +156,7 @@ const MenuDropdown = forwardRef(({ rightSection: option.rightSection, ariaLabel: option.ariaLabel, title: option.title, + submenu: option.submenu, groupStyle: groupStyle }); }); @@ -393,6 +394,8 @@ const MenuDropdown = forwardRef(({ updateOpenState(false)} submenuPlacement={submenuPlacement} getMenuItemProps={getMenuItemProps} diff --git a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts index 31ec57fc0..9c4e34bbd 100644 --- a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts +++ b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts @@ -86,6 +86,8 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true }) — typed va newPage.locator('[data-testid="env-var-row-typed_str"] .type-label').first() ).toHaveText('string'); + await newPage.getByTestId('responsive-tab-secrets').click(); + await expect( newPage.locator('[data-testid="env-var-row-existing_secret"]') ).toBeVisible(); diff --git a/tests/environments/create-environment/global-env-create.spec.ts b/tests/environments/create-environment/global-env-create.spec.ts index ada6c95c5..9c22d6259 100644 --- a/tests/environments/create-environment/global-env-create.spec.ts +++ b/tests/environments/create-environment/global-env-create.spec.ts @@ -7,13 +7,19 @@ import { saveEnvironment, sendRequest, expectResponseContains, - closeAllCollections + closeAllCollections, + deleteAllGlobalEnvironments } from '../../utils/page'; import { buildCommonLocators } from '../../utils/page/locators'; test.describe('Global Environment Create Tests', () => { test.setTimeout(60000); + test.afterEach(async ({ page }) => { + await deleteAllGlobalEnvironments(page); + await closeAllCollections(page); + }); + test('should import collection and create global environment for request usage', async ({ page, createTmpDir @@ -56,9 +62,5 @@ test.describe('Global Environment Create Tests', () => { '"apiToken": "global-secret-token-12345"' ]); }); - - await test.step('Cleanup', async () => { - await closeAllCollections(page); - }); }); }); diff --git a/tests/environments/datatype-preservation/datatype-preservation.spec.ts b/tests/environments/datatype-preservation/datatype-preservation.spec.ts index 301c092e0..ce897b955 100644 --- a/tests/environments/datatype-preservation/datatype-preservation.spec.ts +++ b/tests/environments/datatype-preservation/datatype-preservation.spec.ts @@ -207,10 +207,15 @@ for (const { format, collectionName } of FORMATS) { await test.step('Verify the imported env editor shows datatypes correctly', async () => { await expect(locators.tabs.activeRequestTab()).toContainText('Environments'); - for (const [name, label] of [...TYPED_LABEL_ROWS, ...MISMATCHED_LABEL_ROWS]) { + // Secrets live on their own tab, so assert per-tab. The fixture names every + // secret var `env_secret_*`, so its name is a reliable tab discriminator. + const isSecret = (name: string) => name.includes('secret'); + + // Variables tab (default): the non-secret typed, mismatched, and string rows. + for (const [name, label] of [...TYPED_LABEL_ROWS, ...MISMATCHED_LABEL_ROWS].filter(([n]) => !isSecret(n))) { await expectTypeLabel(page, name, label); } - for (const name of STRING_LABEL_ROWS) { + for (const name of STRING_LABEL_ROWS.filter((n) => !isSecret(n))) { await expectTypeLabel(page, name, 'string'); } @@ -221,6 +226,16 @@ for (const { format, collectionName } of FORMATS) { await expectNoMismatch(page, 'env_num'); await expectNoMismatch(page, 'env_untyped_obj'); + // Secrets tab: the secret typed rows keep their dataType label; the bare-string + // secrets fall back to 'string'. + await locators.environment.secretsTab().click(); + for (const [name, label] of TYPED_LABEL_ROWS.filter(([n]) => isSecret(n))) { + await expectTypeLabel(page, name, label); + } + for (const name of STRING_LABEL_ROWS.filter((n) => isSecret(n))) { + await expectTypeLabel(page, name, 'string'); + } + await closeEnvEditor(page); }); }); 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 5b3e40e21..634cd1530 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 @@ -44,6 +44,7 @@ test.describe.serial('Collection Environment Import Tests', () => { await expect(envTab).toBeVisible(); await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + await page.getByTestId('responsive-tab-secrets').click(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); await envTab.hover(); @@ -125,6 +126,9 @@ test.describe.serial('Collection Environment Import Tests', () => { // Verify prod environment variables await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + + // secretToken was imported as a secret, so it lives on the Secrets tab, not Variables. + await page.getByTestId('responsive-tab-secrets').click(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); await envTab.hover(); 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 db4d41c56..a1f157861 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 @@ -59,6 +59,7 @@ test.describe.serial('Global Environment Import Tests', () => { // Verify imported variables await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + await page.getByTestId('responsive-tab-secrets').click(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); await envTab.hover(); @@ -142,6 +143,9 @@ test.describe.serial('Global Environment Import Tests', () => { // Verify imported variables await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + + // secretToken was imported as a secret, so it lives on the Secrets tab, not Variables. + await page.getByTestId('responsive-tab-secrets').click(); await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); await envTab.hover(); diff --git a/tests/environments/import-environment/collection-env-import.spec.ts b/tests/environments/import-environment/collection-env-import.spec.ts index 61a816d4c..9c09a6743 100644 --- a/tests/environments/import-environment/collection-env-import.spec.ts +++ b/tests/environments/import-environment/collection-env-import.spec.ts @@ -68,8 +68,10 @@ test.describe('Collection Environment Import Tests', () => { await expect(page.locator('input[name$=".name"][value="postTitle"]')).toBeVisible(); await expect(page.locator('input[name$=".name"][value="postBody"]')).toBeVisible(); - await expect(page.locator('input[name$=".name"][value="secretApiToken"]')).toBeVisible(); - await expect(page.locator('input[name="5.secret"]')).toBeChecked(); + await expect(page.locator('input[name$=".name"][value="secretApiToken"]')).toHaveCount(0); + await page.getByTestId('responsive-tab-secrets').click(); + await expect(page.getByTestId('env-var-row-secretApiToken')).toBeVisible(); + await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); diff --git a/tests/environments/import-environment/global-env-import.spec.ts b/tests/environments/import-environment/global-env-import.spec.ts index ca6150165..f3f733c3c 100644 --- a/tests/environments/import-environment/global-env-import.spec.ts +++ b/tests/environments/import-environment/global-env-import.spec.ts @@ -63,8 +63,10 @@ test.describe('Global Environment Import Tests', () => { await expect(variablesTable.locator('input[name$=".name"][value="postTitle"]')).toBeVisible(); await expect(variablesTable.locator('input[name$=".name"][value="postBody"]')).toBeVisible(); - await expect(variablesTable.locator('input[name$=".name"][value="secretApiToken"]')).toBeVisible(); - await expect(variablesTable.locator('input[name="5.secret"]')).toBeChecked(); + await expect(variablesTable.locator('input[name$=".name"][value="secretApiToken"]')).toHaveCount(0); + await page.getByTestId('responsive-tab-secrets').click(); + await expect(page.getByTestId('env-var-row-secretApiToken')).toBeVisible(); + await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); diff --git a/tests/environments/save-shortcut/save-shortcut.spec.ts b/tests/environments/save-shortcut/save-shortcut.spec.ts new file mode 100644 index 000000000..33868b144 --- /dev/null +++ b/tests/environments/save-shortcut/save-shortcut.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '../../../playwright'; +import path from 'path'; +import { Page } from '@playwright/test'; +import { + importCollection, + createEnvironment, + addRowToActiveTab, + closeAllCollections, + deleteAllGlobalEnvironments +} from '../../utils/page'; + +const collectionFile = path.join(__dirname, '..', 'create-environment', 'fixtures', 'bruno-collection.json'); + +const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; +const tabDraftIcon = (page: Page) => page.locator('.request-tab.active').getByTestId('tab-draft-icon'); + +const saveWithShortcut = async (page: Page) => { + await page.keyboard.press(`${modifier}+KeyS`); +}; + +test.describe('Environment save shortcut (Cmd/Ctrl+S)', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('saves a collection environment and shows the success toast', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('env-save-shortcut'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Shortcut Save Env', 'collection'); + + await test.step('Add a variable to create an unsaved draft', async () => { + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await expect(tabDraftIcon(page)).toBeVisible(); + }); + + await test.step('Pressing the save shortcut shows the success toast', async () => { + await saveWithShortcut(page); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('The unsaved-changes dot is gone once saved', async () => { + await expect(tabDraftIcon(page)).not.toBeVisible(); + }); + }); +}); + +test.describe('Global environment save shortcut (Cmd/Ctrl+S)', () => { + test.afterEach(async ({ page }) => { + await deleteAllGlobalEnvironments(page); + await closeAllCollections(page); + }); + + test('saves a global environment and shows the success toast', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('global-env-save-shortcut'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Global Shortcut Save Env', 'global'); + + await test.step('Add a variable to create an unsaved draft', async () => { + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await expect(tabDraftIcon(page)).toBeVisible(); + }); + + await test.step('Pressing the save shortcut shows the success toast', async () => { + await saveWithShortcut(page); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('The unsaved-changes dot is gone once saved', async () => { + await expect(tabDraftIcon(page)).not.toBeVisible(); + }); + }); +}); diff --git a/tests/environments/variable-secret-tabs/variable-secret-tabs.spec.ts b/tests/environments/variable-secret-tabs/variable-secret-tabs.spec.ts new file mode 100644 index 000000000..0f8966264 --- /dev/null +++ b/tests/environments/variable-secret-tabs/variable-secret-tabs.spec.ts @@ -0,0 +1,397 @@ +import { test, expect } from '../../../playwright'; +import path from 'path'; +import { Page } from '@playwright/test'; +import { importCollection, createEnvironment, closeAllCollections, addRowToActiveTab, saveEnvironment, deleteAllGlobalEnvironments } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const variablesTab = (page: Page) => buildCommonLocators(page).environment.variablesTab(); +const secretsTab = (page: Page) => buildCommonLocators(page).environment.secretsTab(); +const varRow = (page: Page, name: string) => buildCommonLocators(page).environment.varRow(name); +const saveTab = (page: Page) => buildCommonLocators(page).environment.saveTab(); +const tabDraftIcon = (page: Page) => page.locator('.request-tab.active').getByTestId('tab-draft-icon'); + +const searchEnv = async (page: Page, query: string) => { + const input = page.locator('.search-input'); + if ((await input.count()) === 0) { + await page.locator('.env-search-container button[title="Search"]').click(); + await input.waitFor({ state: 'visible' }); + } + await input.fill(query); +}; + +const deleteRow = async (page: Page, name: string) => { + await varRow(page, name).locator('button:has(.icon-tabler-trash)').click(); +}; + +const collectionFile = path.join(__dirname, '..', 'create-environment', 'fixtures', 'bruno-collection.json'); + +test.describe('Environment Variables / Secrets tab separation', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('keeps variables and secrets on their own tabs', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('var-secret-tabs'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Tab Separation Env', 'collection'); + + await test.step('Add a variable on the Variables tab', async () => { + await expect(variablesTab(page)).toHaveClass(/active/); + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await expect(varRow(page, 'host')).toBeVisible(); + }); + + await test.step('Add a secret on the Secrets tab', async () => { + await secretsTab(page).click(); + await expect(secretsTab(page)).toHaveClass(/active/); + + await expect(varRow(page, 'host')).toHaveCount(0); + + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await expect(varRow(page, 'apiToken')).toBeVisible(); + }); + + await test.step('Variables tab shows only the variable, not the secret', async () => { + await variablesTab(page).click(); + await expect(variablesTab(page)).toHaveClass(/active/); + await expect(varRow(page, 'host')).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + }); + }); + + test('saves variables and secrets independently and persists both', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('var-secret-save'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Independent Save Env', 'collection'); + + await test.step('Add and save a variable on the Variables tab', async () => { + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await saveTab(page).click(); + // newest toast: the two back-to-back saves can briefly show two identical toasts + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('Add and save a secret on the Secrets tab', async () => { + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('Saving the Secrets tab did not wipe the saved variable', async () => { + await variablesTab(page).click(); + await expect(varRow(page, 'host')).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + + await secretsTab(page).click(); + await expect(varRow(page, 'apiToken')).toBeVisible(); + await expect(varRow(page, 'host')).toHaveCount(0); + }); + }); + + test('common save icon persists both variables and secrets at once', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('var-secret-save-all'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Save All Env', 'collection'); + + await test.step('Add a variable on the Variables tab without saving', async () => { + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + }); + + await test.step('Add a secret on the Secrets tab without saving', async () => { + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + }); + + await test.step('The environment tab shows the unsaved-changes dot while drafts exist', async () => { + await expect(tabDraftIcon(page)).toBeVisible(); + }); + + await test.step('The common save icon saves both tabs in a single click', async () => { + await saveEnvironment(page); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('Both the variable and the secret are persisted on their tabs', async () => { + await variablesTab(page).click(); + await expect(varRow(page, 'host')).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + + await secretsTab(page).click(); + await expect(varRow(page, 'apiToken')).toBeVisible(); + await expect(varRow(page, 'host')).toHaveCount(0); + }); + + await test.step('The unsaved-changes dot is gone once both tabs are saved', async () => { + await expect(tabDraftIcon(page)).not.toBeVisible(); + }); + + await test.step('A second save reports nothing to save, proving both were committed', async () => { + await saveEnvironment(page); + await expect(page.getByText('No changes to save')).toBeVisible(); + }); + }); + + test('search is scoped to the active tab', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('var-secret-search'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Search Scope Env', 'collection'); + + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await test.step('Searching the Variables tab never surfaces the secret', async () => { + await variablesTab(page).click(); + await searchEnv(page, 'host'); + await expect(varRow(page, 'host')).toBeVisible(); + + await searchEnv(page, 'apiToken'); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + await expect(page.getByText('No results found')).toBeVisible(); + }); + + await test.step('Searching the Secrets tab never surfaces the variable', async () => { + await secretsTab(page).click(); + await searchEnv(page, 'apiToken'); + await expect(varRow(page, 'apiToken')).toBeVisible(); + + await searchEnv(page, 'host'); + await expect(varRow(page, 'host')).toHaveCount(0); + await expect(page.getByText('No results found')).toBeVisible(); + }); + + // The search query is stored in Redux and persists across environments, so clear + // it before the test ends — otherwise it filters the next test's table to "No + // results" and the empty "Name" row never renders. + await searchEnv(page, ''); + }); + + test('deleting on one tab leaves the other tab untouched', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('var-secret-delete'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Delete Scope Env', 'collection'); + + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await test.step('Delete the secret on the Secrets tab', async () => { + await deleteRow(page, 'apiToken'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + }); + + await test.step('The variable on the Variables tab is untouched', async () => { + await variablesTab(page).click(); + await expect(varRow(page, 'host')).toBeVisible(); + }); + }); +}); + +test.describe('Global Environment Variables / Secrets tab separation', () => { + test.afterEach(async ({ page }) => { + await deleteAllGlobalEnvironments(page); + await closeAllCollections(page); + }); + + test('keeps variables and secrets on their own tabs', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('global-var-secret-tabs'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Global Tab Separation Env', 'global'); + + await test.step('Add a variable on the Variables tab', async () => { + await expect(variablesTab(page)).toHaveClass(/active/); + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await expect(varRow(page, 'host')).toBeVisible(); + }); + + await test.step('Add a secret on the Secrets tab', async () => { + await secretsTab(page).click(); + await expect(secretsTab(page)).toHaveClass(/active/); + + await expect(varRow(page, 'host')).toHaveCount(0); + + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await expect(varRow(page, 'apiToken')).toBeVisible(); + }); + + await test.step('Variables tab shows only the variable, not the secret', async () => { + await variablesTab(page).click(); + await expect(variablesTab(page)).toHaveClass(/active/); + await expect(varRow(page, 'host')).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + }); + }); + + test('saves variables and secrets independently and persists both', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('global-var-secret-save'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Global Independent Save Env', 'global'); + + await test.step('Add and save a variable on the Variables tab', async () => { + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await saveTab(page).click(); + // newest toast: the two back-to-back saves can briefly show two identical toasts + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('Add and save a secret on the Secrets tab', async () => { + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('Saving the Secrets tab did not wipe the saved variable', async () => { + await variablesTab(page).click(); + await expect(varRow(page, 'host')).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + + await secretsTab(page).click(); + await expect(varRow(page, 'apiToken')).toBeVisible(); + await expect(varRow(page, 'host')).toHaveCount(0); + }); + }); + + test('common save icon persists both variables and secrets at once', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('global-var-secret-save-all'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Global Save All Env', 'global'); + + await test.step('Add a variable on the Variables tab without saving', async () => { + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + }); + + await test.step('Add a secret on the Secrets tab without saving', async () => { + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + }); + + await test.step('The environment tab shows the unsaved-changes dot while drafts exist', async () => { + await expect(tabDraftIcon(page)).toBeVisible(); + }); + + await test.step('The common save icon saves both tabs in a single click', async () => { + await saveEnvironment(page); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + }); + + await test.step('Both the variable and the secret are persisted on their tabs', async () => { + await variablesTab(page).click(); + await expect(varRow(page, 'host')).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + + await secretsTab(page).click(); + await expect(varRow(page, 'apiToken')).toBeVisible(); + await expect(varRow(page, 'host')).toHaveCount(0); + }); + + await test.step('The unsaved-changes dot is gone once both tabs are saved', async () => { + await expect(tabDraftIcon(page)).not.toBeVisible(); + }); + + await test.step('A second save reports nothing to save, proving both were committed', async () => { + await saveEnvironment(page); + await expect(page.getByText('No changes to save')).toBeVisible(); + }); + }); + + test('search is scoped to the active tab', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('global-var-secret-search'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Global Search Scope Env', 'global'); + + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await test.step('Searching the Variables tab never surfaces the secret', async () => { + await variablesTab(page).click(); + await searchEnv(page, 'host'); + await expect(varRow(page, 'host')).toBeVisible(); + + await searchEnv(page, 'apiToken'); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + await expect(page.getByText('No results found')).toBeVisible(); + }); + + await test.step('Searching the Secrets tab never surfaces the variable', async () => { + await secretsTab(page).click(); + await searchEnv(page, 'apiToken'); + await expect(varRow(page, 'apiToken')).toBeVisible(); + + await searchEnv(page, 'host'); + await expect(varRow(page, 'host')).toHaveCount(0); + await expect(page.getByText('No results found')).toBeVisible(); + }); + + // The search query is stored in Redux and persists across environments, so clear + // it before the test ends — otherwise it filters the next test's table to "No + // results" and the empty "Name" row never renders. + await searchEnv(page, ''); + }); + + test('deleting on one tab leaves the other tab untouched', async ({ page, createTmpDir }) => { + await importCollection(page, collectionFile, await createTmpDir('global-var-secret-delete'), { + expectedCollectionName: 'test_collection' + }); + + await createEnvironment(page, 'Global Delete Scope Env', 'global'); + + await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await secretsTab(page).click(); + await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + + await test.step('Delete the secret on the Secrets tab', async () => { + await deleteRow(page, 'apiToken'); + await saveTab(page).click(); + await expect(page.getByText('Changes saved successfully').last()).toBeVisible(); + await expect(varRow(page, 'apiToken')).toHaveCount(0); + }); + + await test.step('The variable on the Variables tab is untouched', async () => { + await variablesTab(page).click(); + await expect(varRow(page, 'host')).toBeVisible(); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index a3a9f5342..063bdb022 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -680,39 +680,28 @@ type EnvironmentVariable = { }; /** - * Add an environment variable to the currently open environment + * Add an environment variable to the currently open environment. Variables and + * secrets live on separate tabs, so a secret is routed to the Secrets tab and a + * plain variable to the Variables tab before the row is added. * @param page - The page object * @param variable - The variable to add (name, value, and optional secret flag) - * @param index - The index of the variable (0-based) * @returns void */ -const addEnvironmentVariable = async ( - page: Page, - variable: EnvironmentVariable, - index: number -) => { - await test.step(`Add environment variable "${variable.name}"`, async () => { - const nameInput = page.locator(`input[name="${index}.name"]`); - await nameInput.waitFor({ state: 'visible' }); - await nameInput.fill(variable.name); +const addEnvironmentVariable = async (page: Page, variable: EnvironmentVariable) => { + await test.step(`Add environment ${variable.isSecret ? 'secret' : 'variable'} "${variable.name}"`, async () => { + const tab = variable.isSecret + ? page.getByTestId('responsive-tab-secrets') + : page.getByTestId('responsive-tab-variables'); + await tab.click(); + await expect(tab).toHaveClass(/active/); - // Wait for the CodeMirror editor in the row to be ready - const variableRow = page.locator('tr').filter({ has: page.locator(`input[name="${index}.name"]`) }); - const codeMirror = variableRow.locator('.CodeMirror'); - await codeMirror.waitFor({ state: 'visible' }); - await codeMirror.click(); - await page.keyboard.type(variable.value); - - if (variable.isSecret) { - const secretCheckbox = page.locator(`input[name="${index}.secret"]`); - await secretCheckbox.waitFor({ state: 'visible' }); - await secretCheckbox.check(); - } + await addRowToActiveTab(page, variable.name, variable.value); }); }; /** - * Add multiple environment variables to the currently open environment + * Add multiple environment variables to the currently open environment. Each entry + * is routed to the Variables or Secrets tab based on its `isSecret` flag. * @param page - The page object * @param variables - Array of variables to add * @returns void @@ -720,7 +709,65 @@ const addEnvironmentVariable = async ( const addEnvironmentVariables = async (page: Page, variables: EnvironmentVariable[]) => { await test.step(`Add ${variables.length} environment variables`, async () => { for (let i = 0; i < variables.length; i++) { - await addEnvironmentVariable(page, variables[i], i); + await addEnvironmentVariable(page, variables[i]); + } + }); +}; + +/** + * Add a variable or secret to whichever environment tab (Variables / Secrets) is + * currently active. The active tab determines the row's type, so select the tab + * before calling. + * @param page - The page object + * @param name - The variable/secret name + * @param value - The variable/secret value + * @returns void + */ +const addRowToActiveTab = async (page: Page, name: string, value: string) => { + await test.step(`Add row "${name}" to the active environment tab`, async () => { + const nameInput = page.locator('input[placeholder="Name"]').last(); + await nameInput.waitFor({ state: 'visible' }); + await nameInput.fill(name); + + const row = page.getByTestId(`env-var-row-${name}`); + await row.waitFor({ state: 'visible' }); + + const codeMirror = row.locator('.CodeMirror'); + await codeMirror.scrollIntoViewIfNeeded(); + await codeMirror.click(); + await page.keyboard.type(value); + }); +}; + +/** + * Delete every global environment in the workspace. Global environments persist at + * the workspace level (closeAllCollections does not remove them), so call this to keep + * tests isolated. Deletes the currently-selected environment first, since a tab with + * unsaved changes blocks switching to another env via the list. + * @param page - The page object + * @returns void + */ +const deleteAllGlobalEnvironments = async (page: Page) => { + await test.step('Delete all global environments', async () => { + await page.getByTestId('environment-selector-trigger').click(); + await page.getByTestId('env-tab-global').click(); + await page.getByTestId('configure-env').click(); + + const envItems = page.locator('.environment-item'); + const deleteBtn = page.locator('button[title="Delete"]'); + const modal = page.locator('.bruno-modal').filter({ hasText: 'Delete Environment' }); + + await page.locator('.environments-container').first().waitFor({ state: 'visible' }).catch(() => {}); + + while (true) { + if ((await deleteBtn.count()) === 0) { + if ((await envItems.count()) === 0) break; + await envItems.first().click(); + await deleteBtn.waitFor({ state: 'visible' }); + } + await deleteBtn.first().click(); + await modal.getByRole('button', { name: 'Delete', exact: true }).click(); + await modal.waitFor({ state: 'hidden' }); } }); }; @@ -732,7 +779,7 @@ const addEnvironmentVariables = async (page: Page, variables: EnvironmentVariabl */ const saveEnvironment = async (page: Page) => { await test.step('Save environment', async () => { - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByTestId('save-all-env').click(); }); }; @@ -2154,6 +2201,8 @@ export { createEnvironment, addEnvironmentVariable, addEnvironmentVariables, + addRowToActiveTab, + deleteAllGlobalEnvironments, saveEnvironment, closeEnvironmentPanel, selectEnvironment, diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 04f682caa..c0b30d4d0 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -87,7 +87,12 @@ export const buildCommonLocators = (page: Page) => ({ variableSecretCheckbox: (index: number) => page.locator(`input[name="${index}.secret"]`), variableRow: (index: number) => page.locator('tr').filter({ has: page.locator(`input[name="${index}.name"]`) }), createEnvButton: () => page.locator('button[id="create-env"]'), - envNameInput: () => page.locator('input[name="name"]') + envNameInput: () => page.locator('input[name="name"]'), + // Variables and secrets each live on their own tab in the environment editor. + variablesTab: () => page.getByTestId('responsive-tab-variables'), + secretsTab: () => page.getByTestId('responsive-tab-secrets'), + saveTab: () => page.getByTestId('save-env'), + saveAll: () => page.getByTestId('save-all-env') }, codeMirror: { byTestId: (testId: string) => page.getByTestId(testId).locator('.CodeMirror').first() diff --git a/tests/variable-datatypes/create-via-ui.spec.ts b/tests/variable-datatypes/create-via-ui.spec.ts index 0b7c7706d..d70a0b82b 100644 --- a/tests/variable-datatypes/create-via-ui.spec.ts +++ b/tests/variable-datatypes/create-via-ui.spec.ts @@ -180,12 +180,16 @@ test.describe('DataType selector — new collection created via UI', () => { await expect(page.locator('.request-tab').filter({ hasText: 'Environments' })).toBeVisible(); const envRows = locators.environment.varRows(); - // Named rows added so far; the table always keeps one trailing empty stub, - // so after N adds the row count (including the stub) is N + 1. - let addedEnvVars = 0; + // Named rows added on the currently active tab; the table always keeps one + // trailing empty stub, so after N adds the visible row count is N + 1. + // Variables and secrets live on separate tabs, so the count is per-tab and + // resets when we switch tabs. + let tabRowCount = 0; - // Add one env var row: fill name + value, optionally mark it secret, then - // pick its dataType. Secrets render the DataTypeSelector too (value masks). + // Add one env var row on the active tab: fill name + value, then pick its + // dataType. A row is a secret by virtue of being added on the Secrets tab, + // so the caller switches tabs before adding secrets. Secrets render the + // DataTypeSelector too. const addEnvVar = async (name: string, dataType: NonDefaultDataType, { secret = false } = {}) => { await test.step(`add ${secret ? 'secret ' : ''}${dataType} env var "${name}"`, async () => { const emptyRow = page.locator('tbody tr').last(); @@ -195,20 +199,14 @@ test.describe('DataType selector — new collection created via UI', () => { // EnvironmentVariablesTable.handleNameChange appends a trailing empty // row via setTimeout(0). If we click the value editor before that // append re-renders, focus can be dropped — wait for the new row. - addedEnvVars++; - await expect(envRows).toHaveCount(addedEnvVars + 1); + tabRowCount++; + await expect(envRows).toHaveCount(tabRowCount + 1); const valueEditor = namedRow.locator('.CodeMirror').first(); await valueEditor.click({ force: true }); await expect(valueEditor).toHaveClass(/CodeMirror-focused/); await page.keyboard.insertText(VALUE_FOR_DATATYPE[dataType]); - if (secret) { - const secretCheckbox = locators.environment.varRowSecretCheckbox(name); - await secretCheckbox.check(); - await expect(secretCheckbox).toBeChecked(); - } - await locators.dataTypeSelector.typeLabel(namedRow).click(); await locators.dataTypeSelector.menuItem(dataType).click(); await expect(locators.dataTypeSelector.typeLabel(namedRow)).toHaveText(dataType); @@ -219,22 +217,27 @@ test.describe('DataType selector — new collection created via UI', () => { for (const dt of TYPED_DATATYPES) { await addEnvVar(`env_${dt}`, dt); } - // Secrets render the DataTypeSelector too — one secret per dataType. + // Secrets live on their own tab; switch over and add them there. The active + // tab is now Secrets, so each new row is created as a secret automatically. + await locators.environment.secretsTab().click(); + tabRowCount = 0; for (const dt of TYPED_DATATYPES) { await addEnvVar(`env_secret_${dt}`, dt, { secret: true }); } - await locators.environment.saveButton().click(); + // save-all persists both tabs at once (save-env is scoped to the active tab). + await locators.environment.saveAll().click(); await page.waitForTimeout(500); - // Re-assert after save (post-formik-reset). + // Re-assert after save (post-formik-reset). The Secrets tab is still active, + // so the secret rows render here; each keeps its dataType. + for (const dt of TYPED_DATATYPES) { + await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(`env_secret_${dt}`))).toHaveText(dt); + } + // Switch back to the Variables tab to verify the non-secret rows. + await locators.environment.variablesTab().click(); for (const dt of TYPED_DATATYPES) { await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(`env_${dt}`))).toHaveText(dt); } - // Each secret var keeps both its secret flag and its dataType after save. - for (const dt of TYPED_DATATYPES) { - await expect(locators.environment.varRowSecretCheckbox(`env_secret_${dt}`)).toBeChecked(); - await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(`env_secret_${dt}`))).toHaveText(dt); - } }); }); diff --git a/tests/variable-datatypes/parsed-from-fixture.spec.ts b/tests/variable-datatypes/parsed-from-fixture.spec.ts index ebcf78e99..1ee9a6374 100644 --- a/tests/variable-datatypes/parsed-from-fixture.spec.ts +++ b/tests/variable-datatypes/parsed-from-fixture.spec.ts @@ -267,9 +267,15 @@ const openEnvironmentSettings = async (page: Page, type: 'collection' | 'global' await expect(locators.tabs.activeRequestTab()).toContainText(tabTitle); }; -/** Assert the DataTypeSelector inside an env-var row reports the expected label. */ -const expectEnvVarTypeLabel = async (page: Page, name: string, label: string) => { +/** + * Assert the DataTypeSelector inside an env-var row reports the expected label. + * Variables and secrets render on separate tabs in the env editor, so activate + * the tab that owns this var before asserting (pass `{ secret: true }` for + * secret rows, which live on the Secrets tab). + */ +const expectEnvVarTypeLabel = async (page: Page, name: string, label: string, { secret = false } = {}) => { const { environment, dataTypeSelector } = buildCommonLocators(page); + await (secret ? environment.secretsTab() : environment.variablesTab()).click(); const row = environment.varRow(name); await scrollVirtuosoRowIntoView(page, row); await expect(dataTypeSelector.typeLabel(row)).toHaveText(label, { timeout: SLOW_RENDER_TIMEOUT_MS }); @@ -288,14 +294,14 @@ const runSecretDataTypeLabelAssertions = async (page: Page, collectionName: stri await selectEnvironment(page, 'variables', 'global'); await openEnvironmentSettings(page, 'collection'); - await expectEnvVarTypeLabel(page, 'env_secret_num', 'number'); - await expectEnvVarTypeLabel(page, 'env_secret_bool', 'boolean'); - await expectEnvVarTypeLabel(page, 'env_secret_obj', 'object'); + await expectEnvVarTypeLabel(page, 'env_secret_num', 'number', { secret: true }); + await expectEnvVarTypeLabel(page, 'env_secret_bool', 'boolean', { secret: true }); + await expectEnvVarTypeLabel(page, 'env_secret_obj', 'object', { secret: true }); await openEnvironmentSettings(page, 'global'); - await expectEnvVarTypeLabel(page, 'glob_secret_num', 'number'); - await expectEnvVarTypeLabel(page, 'glob_secret_bool', 'boolean'); - await expectEnvVarTypeLabel(page, 'glob_secret_obj', 'object'); + await expectEnvVarTypeLabel(page, 'glob_secret_num', 'number', { secret: true }); + await expectEnvVarTypeLabel(page, 'glob_secret_bool', 'boolean', { secret: true }); + await expectEnvVarTypeLabel(page, 'glob_secret_obj', 'object', { secret: true }); }; /** @@ -630,13 +636,13 @@ const runDataTypeSelectorTests = ( await expectEnvVarTypeLabel(page, 'env_untyped_num', 'string'); await expectEnvVarTypeLabel(page, 'env_untyped_bool', 'string'); await expectEnvVarTypeLabel(page, 'env_untyped_obj', 'string'); - await expectEnvVarTypeLabel(page, 'env_secret_untyped', 'string'); + await expectEnvVarTypeLabel(page, 'env_secret_untyped', 'string', { secret: true }); await openEnvironmentSettings(page, 'global'); await expectEnvVarTypeLabel(page, 'glob_untyped_num', 'string'); await expectEnvVarTypeLabel(page, 'glob_untyped_bool', 'string'); await expectEnvVarTypeLabel(page, 'glob_untyped_obj', 'string'); - await expectEnvVarTypeLabel(page, 'glob_secret_untyped', 'string'); + await expectEnvVarTypeLabel(page, 'glob_secret_untyped', 'string', { secret: true }); }); test('env editor: secret variables display their declared dataType label', async ({ pageWithUserData: page }) => {