mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
feat(environments): split variables and secrets into separate tabs (#8191)
This commit is contained in:
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user