feat(environments): split variables and secrets into separate tabs (#8191)

This commit is contained in:
Pooja
2026-06-26 12:21:58 +05:30
committed by GitHub
parent 1ceea0797e
commit d6e17e1dab
32 changed files with 1129 additions and 307 deletions

View File

@@ -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 <EditableTable/> 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 = ({
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
@@ -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)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>

View File

@@ -21,14 +21,14 @@ const DeleteEnvironment = ({ onClose, environment, collection }) => {
<Portal>
<StyledWrapper>
<Modal
size="sm"
size="md"
title="Delete Environment"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
confirmButtonColor="danger"
>
Are you sure you want to delete <span className="font-medium">{environment.name}</span> ?
Are you sure you want to delete <span className="font-medium">{environment.name}</span>?
</Modal>
</StyledWrapper>
</Portal>

View File

@@ -9,7 +9,7 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '', variableType = 'variables' }) => {
const dispatch = useDispatch();
const environmentsDraft = collection?.environmentsDraft;
@@ -92,7 +92,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
return (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
warningMessage="This variable is used in sensitive fields. Add it as a secret in the Secrets tab for security"
/>
);
}
@@ -113,6 +113,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
setIsModified={setIsModified}
renderExtraValueContent={renderExtraValueContent}
searchQuery={searchQuery}
variableType={variableType}
/>
);
};

View File

@@ -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,30 +161,6 @@ 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};
}
}
}
}
@@ -183,6 +170,7 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
margin-top: 16px;
}
`;

View File

@@ -1,6 +1,6 @@
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 } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -8,8 +8,16 @@ import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnv
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import ActionIcon from 'ui/ActionIcon';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
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 environments = collection?.environments || [];
@@ -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() === '') {
@@ -133,6 +145,10 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
};
const handleSaveAll = () => {
window.dispatchEvent(new Event('environment-save-all'));
};
return (
<StyledWrapper>
{openDeleteModal && (
@@ -187,48 +203,66 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
<ActionIcon label="Rename" onClick={handleRenameClick} data-testid="env-rename-action">
<IconEdit size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Copy" onClick={() => setOpenCopyModal(true)} data-testid="env-copy-action">
<IconCopy size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Delete" onClick={() => setOpenDeleteModal(true)} colorOnHover="danger" data-testid="env-delete-action">
<IconTrash size={15} strokeWidth={1.5} />
</ActionIcon>
</div>
</div>
<div className="tabs-container">
<ResponsiveTabs
tabs={TABS}
activeTab={activeTab}
onTabSelect={setActiveTab}
rightContent={(
<div ref={rightContentRef} className="env-search-container">
<ActionIcon label="Save" onClick={handleSaveAll} data-testid="save-all-env">
<IconDeviceFloppy size={15} strokeWidth={1.5} />
</ActionIcon>
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={activeTab === 'secrets' ? 'Search secrets...' : 'Search variables...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
data-testid="env-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
data-testid="env-clear-search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<ActionIcon label="Search" onClick={handleSearchIconClick} data-testid="env-search-action">
<IconSearch size={15} strokeWidth={1.5} />
</ActionIcon>
)}
</div>
) : (
<button onClick={handleSearchIconClick} title="Search variables">
<IconSearch size={15} strokeWidth={1.5} />
</button>
)}
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenCopyModal(true)} title="Copy">
<IconCopy size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</div>
rightContentRef={rightContentRef}
/>
</div>
<div className="content">
@@ -237,6 +271,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
variableType={activeTab}
/>
</div>
</StyledWrapper>

View File

@@ -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;

View File

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

View File

@@ -7,7 +7,7 @@ const GradientCloseButton = ({ onClick, hasChanges = false }) => {
return (
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''}`}>
<div className="close-icon-container" onClick={onClick} data-testid="request-tab-close-icon">
<span className="draft-icon-wrapper">
<span className="draft-icon-wrapper" data-testid="tab-draft-icon">
<DraftTabIcon />
</span>
<span className="close-icon-wrapper">

View File

@@ -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') {

View File

@@ -21,13 +21,13 @@ const DeleteEnvironment = ({ onClose, environment }) => {
<Portal>
<StyledWrapper>
<Modal
size="sm"
size="md"
title="Delete Environment"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
>
Are you sure you want to delete <span className="font-semibold">{environment.name}</span> ?
Are you sure you want to delete <span className="font-semibold">{environment.name}</span>?
</Modal>
</StyledWrapper>
</Portal>

View File

@@ -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}
/>
);
};

View File

@@ -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;
}
`;

View File

@@ -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 (
<StyledWrapper>
{openDeleteModal && (
@@ -189,48 +205,66 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
<ActionIcon label="Rename" onClick={handleRenameClick} data-testid="env-rename-action">
<IconEdit size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Copy" onClick={() => setOpenCopyModal(true)} data-testid="env-copy-action">
<IconCopy size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Delete" onClick={() => setOpenDeleteModal(true)} colorOnHover="danger" data-testid="env-delete-action">
<IconTrash size={15} strokeWidth={1.5} />
</ActionIcon>
</div>
</div>
<div className="tabs-container">
<ResponsiveTabs
tabs={TABS}
activeTab={activeTab}
onTabSelect={setActiveTab}
rightContent={(
<div ref={rightContentRef} className="env-search-container">
<ActionIcon label="Save" onClick={handleSaveAll} data-testid="save-all-env">
<IconDeviceFloppy size={15} strokeWidth={1.5} />
</ActionIcon>
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={activeTab === 'secrets' ? 'Search secrets...' : 'Search variables...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
data-testid="env-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
data-testid="env-clear-search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<ActionIcon label="Search" onClick={handleSearchIconClick} data-testid="env-search-action">
<IconSearch size={15} strokeWidth={1.5} />
</ActionIcon>
)}
</div>
) : (
<button onClick={handleSearchIconClick} title="Search variables">
<IconSearch size={15} strokeWidth={1.5} />
</button>
)}
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenCopyModal(true)} title="Copy">
<IconCopy size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</div>
rightContentRef={rightContentRef}
/>
</div>
<div className="content">
@@ -239,6 +273,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
variableType={activeTab}
/>
</div>
</StyledWrapper>

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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]}

View File

@@ -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(({
<SubMenuItem
key={item.id}
item={item}
selectedItemId={selectedItemId}
showTickMark={showTickMark}
onRootClose={() => updateOpenState(false)}
submenuPlacement={submenuPlacement}
getMenuItemProps={getMenuItemProps}