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 && (
|