Fix Environment Search Behavior, UI Updates, and Result Handling (#7287)

This commit is contained in:
Pragadesh-45
2026-02-26 12:27:03 +05:30
committed by GitHub
parent 81a7544853
commit 4d61ecacb3
10 changed files with 379 additions and 215 deletions

View File

@@ -96,6 +96,36 @@ const Wrapper = styled.div`
max-width: 200px !important;
}
.name-cell-wrapper {
position: relative;
width: 100%;
.name-highlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
white-space: pre;
overflow: hidden;
font-size: inherit;
line-height: inherit;
color: ${(props) => props.theme.text};
}
}
.search-highlight {
background: ${(props) => props.theme.colors.accent}55;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.no-results {
padding: 24px;
text-align: center;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
input[type='text'] {
width: 100%;
border: 1px solid transparent;

View File

@@ -31,6 +31,15 @@ const TableRow = React.memo(
}
);
const highlightText = (text, query) => {
if (!query?.trim() || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
);
};
const EnvironmentVariablesTable = ({
environment,
collection,
@@ -42,7 +51,8 @@ const EnvironmentVariablesTable = ({
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme } = useTheme();
const { storedTheme, theme } = useTheme();
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
@@ -50,6 +60,7 @@ const EnvironmentVariablesTable = ({
const [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -407,139 +418,160 @@ const EnvironmentVariablesTable = ({
const query = searchQuery.toLowerCase().trim();
return allVariables.filter(({ variable, index }) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return true;
}
return allVariables.filter(({ variable }) => {
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery]);
const isSearchActive = !!searchQuery?.trim();
return (
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
{isSearchActive && filteredVariables.length === 0 ? (
<div className="no-results">No results found for &ldquo;{searchQuery.trim()}&rdquo;</div>
) : (
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
}}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
const activeQuery = searchQuery?.trim().toLowerCase();
const valueMatchesOnly = activeQuery
&& !(variable.name?.toLowerCase().includes(activeQuery))
&& typeof variable.value === 'string'
&& variable.value.toLowerCase().includes(activeQuery);
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
/>
</span>
)}
{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)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<div className="name-cell-wrapper">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
readOnly={isSearchActive}
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
onBlur={() => {
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
}}
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
/>
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
<div className="name-highlight-overlay">
{highlightText(variable.name || '', searchQuery)}
</div>
)}
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={isSearchActive || typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
}}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
)}
<div className="button-container">
<div className="flex items-center">

View File

@@ -55,6 +55,7 @@ const StyledWrapper = styled.div`
}
.section-title {
padding-right: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;

View File

@@ -1,7 +1,6 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -11,7 +10,7 @@ import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
@@ -20,11 +19,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {

View File

@@ -32,19 +32,6 @@ const StyledWrapper = styled.div`
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
@@ -66,35 +53,54 @@ const StyledWrapper = styled.div`
}
}
.search-container {
.env-list-search {
position: relative;
padding: 0 12px 12px 12px;
.search-icon {
display: flex;
align-items: center;
margin: 0 4px 6px 4px;
.env-list-search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-100%);
left: 8px;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
.env-list-search-input {
width: 100%;
padding: 6px 8px 6px 28px;
padding: 5px 24px 5px 26px;
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
transition: border-color 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.accent};
}
}
.env-list-search-clear {
position: absolute;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
border-radius: 3px;
&:hover {
color: ${(props) => props.theme.text};
}
}
}
@@ -130,6 +136,10 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&.active {
color: ${(props) => props.theme.colors.accent};
}
}
.environment-item {

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import useDebounce from 'hooks/useDebounce';
import EnvironmentDetails from './EnvironmentDetails';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
@@ -23,6 +24,7 @@ import {
deleteDotEnvFile
} from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
@@ -40,9 +42,15 @@ const EnvironmentList = ({
setShowExportModal
}) => {
const dispatch = useDispatch();
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v }));
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
@@ -65,6 +73,9 @@ const EnvironmentList = ({
const dotEnvInputRef = useRef(null);
const dotEnvCreateContainerRef = useRef(null);
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
const envSearchInputRef = useRef(null);
const dotEnvFiles = useSelector((state) => {
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
return coll?.dotEnvFiles || EMPTY_ARRAY;
@@ -497,6 +508,12 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -531,20 +548,6 @@ const EnvironmentList = ({
)}
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="sections-container">
<CollapsibleSection
@@ -553,6 +556,19 @@ const EnvironmentList = ({
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button
type="button"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
onClick={() => {
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
}}
title="Search environments"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
@@ -565,6 +581,28 @@ const EnvironmentList = ({
</>
)}
>
{isEnvListSearchExpanded && (
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
)}
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div

View File

@@ -1,7 +1,6 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -11,7 +10,7 @@ import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const dispatch = useDispatch();
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
@@ -20,11 +19,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {

View File

@@ -32,19 +32,7 @@ const StyledWrapper = styled.div`
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
@@ -66,35 +54,54 @@ const StyledWrapper = styled.div`
}
}
.search-container {
.env-list-search {
position: relative;
padding: 0 12px 12px 12px;
.search-icon {
display: flex;
align-items: center;
margin: 0 4px 6px 4px;
.env-list-search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-100%);
left: 8px;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
.env-list-search-input {
width: 100%;
padding: 6px 8px 6px 28px;
padding: 5px 24px 5px 26px;
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
transition: border-color 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.accent};
}
}
.env-list-search-clear {
position: absolute;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
border-radius: 3px;
&:hover {
color: ${(props) => props.theme.text};
}
}
}
@@ -130,6 +137,10 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&.active {
color: ${(props) => props.theme.colors.accent};
}
}
.environment-item {

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import useDebounce from 'hooks/useDebounce';
import EnvironmentDetails from './EnvironmentDetails';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
@@ -20,6 +21,7 @@ import {
createWorkspaceDotEnvFile,
deleteWorkspaceDotEnvFile
} from 'providers/ReduxStore/slices/workspaces/actions';
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
@@ -39,9 +41,15 @@ const EnvironmentList = ({
}) => {
const dispatch = useDispatch();
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.global?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.global?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'global', query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'global', expanded: v }));
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
@@ -64,6 +72,9 @@ const EnvironmentList = ({
const dotEnvInputRef = useRef(null);
const dotEnvCreateContainerRef = useRef(null);
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
const envSearchInputRef = useRef(null);
const dotEnvFiles = useSelector((state) => {
const ws = state.workspaces.workspaces.find((w) => w.uid === workspace?.uid);
return ws?.dotEnvFiles || EMPTY_ARRAY;
@@ -493,6 +504,12 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -525,20 +542,6 @@ const EnvironmentList = ({
)}
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="sections-container">
<CollapsibleSection
@@ -547,6 +550,19 @@ const EnvironmentList = ({
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button
type="button"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
onClick={() => {
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
}}
title="Search environments"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
@@ -559,6 +575,28 @@ const EnvironmentList = ({
</>
)}
>
{isEnvListSearchExpanded && (
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
)}
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div

View File

@@ -53,7 +53,11 @@ const initialState = {
clipboard: {
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
},
systemProxyVariables: {}
systemProxyVariables: {},
envVarSearch: {
collection: { query: '', expanded: false },
global: { query: '', expanded: false }
}
};
export const appSlice = createSlice({
@@ -141,6 +145,14 @@ export const appSlice = createSlice({
setClipboard: (state, action) => {
// Update clipboard UI state
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
},
setEnvVarSearchQuery: (state, { payload: { context, query } }) => {
if (!state.envVarSearch[context]) return;
state.envVarSearch[context].query = query;
},
setEnvVarSearchExpanded: (state, { payload: { context, expanded } }) => {
if (!state.envVarSearch[context]) return;
state.envVarSearch[context].expanded = expanded;
}
},
extraReducers: (builder) => {
@@ -182,7 +194,9 @@ export const {
updateGitOperationProgress,
removeGitOperationProgress,
setGitVersion,
setClipboard
setClipboard,
setEnvVarSearchQuery,
setEnvVarSearchExpanded
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {