feat: add search functionality to environment variables. (#6659)

This commit is contained in:
Pragadesh-45
2026-01-29 12:16:51 +05:30
committed by GitHub
parent 6e34fbd0ce
commit 5cdb8ae0a1
6 changed files with 262 additions and 42 deletions

View File

@@ -19,15 +19,15 @@ import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'uti
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { sensitiveFields } from './constants';
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
const TableRow = React.memo(({ children, item }) => <tr key={item.variable.uid} data-testid={`env-var-row-${item.variable.name}`}>{children}</tr>, (prevProps, nextProps) => {
const prevUid = prevProps?.item?.variable?.uid;
const nextUid = nextProps?.item?.variable?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
});
const MIN_H = 35 * 2; // 2 rows worth of height
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
@@ -397,13 +397,35 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
};
}, []);
const filteredVariables = useMemo(() => {
const allVariables = formik.values.map((variable, index) => ({ variable, index }));
if (!searchQuery?.trim()) {
return allVariables;
}
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;
}
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]);
return (
<StyledWrapper>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={formik.values}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
@@ -415,9 +437,9 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, variable) => variable.uid}
itemContent={(index, variable) => {
const isLastRow = index === formik.values.length - 1;
computeItemKey={(index, item) => item.variable.uid}
itemContent={(index, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
@@ -428,7 +450,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
@@ -443,15 +465,15 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
@@ -459,12 +481,12 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
@@ -490,7 +512,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>

View File

@@ -94,8 +94,63 @@ const StyledWrapper = styled.div`
.actions {
display: flex;
align-items: center;
gap: 2px;
.search-container {
position: relative;
top: 6px;
display: flex;
align-items: center;
.search-icon {
position: absolute;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 200px;
padding: 5px 32px 5px 32px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
}
.clear-search {
position: absolute;
right: 8px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: all 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
}
button {
display: inline-flex;
align-items: center;

View File

@@ -1,6 +1,7 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
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 } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -18,6 +19,8 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const validateEnvironmentName = (name) => {
@@ -162,6 +165,29 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={() => setSearchQuery('')}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
@@ -175,7 +201,12 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
/>
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
@@ -22,13 +22,13 @@ import Button from 'ui/Button';
const MIN_H = 35 * 2;
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
const TableRow = React.memo(({ children, item }) => <tr key={item.variable.uid} data-testid={`env-var-row-${item.variable.name}`}>{children}</tr>, (prevProps, nextProps) => {
const prevUid = prevProps?.item?.variable?.uid;
const nextUid = nextProps?.item?.variable?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
});
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid, globalEnvironmentDraft } = useSelector(
@@ -335,16 +335,38 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
};
}, []);
const filteredVariables = useMemo(() => {
const allVariables = formik.values.map((variable, index) => ({ variable, index }));
if (!searchQuery?.trim()) {
return allVariables;
}
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;
}
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]);
return (
<StyledWrapper>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
totalListHeightChanged={handleTotalHeightChanged}
data={formik.values}
data={filteredVariables}
fixedItemHeight={35}
components={{ TableRow }}
computeItemKey={(index, variable) => variable.uid}
computeItemKey={(index, item) => item.variable.uid}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
@@ -354,8 +376,9 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
<td></td>
</tr>
)}
itemContent={(index, variable) => {
const isLastRow = index === formik.values.length - 1;
itemContent={(index, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
@@ -366,7 +389,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
@@ -381,15 +404,15 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
@@ -397,12 +420,12 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
@@ -422,7 +445,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>

View File

@@ -94,8 +94,66 @@ const StyledWrapper = styled.div`
.actions {
display: flex;
align-items: center;
gap: 2px;
.search-container {
position: relative;
top: 6px;
display: flex;
align-items: center;
// margin-right: 8px;
// margin-top: 8px;
.search-icon {
position: absolute;
// left: 10px;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 200px;
padding: 5px 32px 5px 32px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
}
.clear-search {
position: absolute;
right: 8px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: all 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
}
button {
display: inline-flex;
align-items: center;

View File

@@ -1,6 +1,7 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
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 } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -18,6 +19,8 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const validateEnvironmentName = (name) => {
@@ -164,6 +167,29 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={() => setSearchQuery('')}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
@@ -177,7 +203,12 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
/>
</div>
</StyledWrapper>
);