fix(app): scope env variable/secret search per tab (#8491)

This commit is contained in:
Pooja
2026-07-07 15:05:29 +05:30
committed by GitHub
parent 6f8865e162
commit 4592d2c30e
7 changed files with 166 additions and 31 deletions

View File

@@ -1,5 +1,5 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconDeviceFloppy } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
@@ -30,6 +30,16 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
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 } }));
// Use the immediate query on a tab switch (debounced value lags and briefly
// flashes the unfiltered table).
const prevTabRef = useRef(activeTab);
const tabJustChanged = prevTabRef.current !== activeTab;
useEffect(() => {
prevTabRef.current = activeTab;
}, [activeTab]);
const tableSearchQuery = tabJustChanged ? searchQuery : debouncedSearchQuery;
const inputRef = useRef(null);
const rightContentRef = useRef(null);
@@ -270,7 +280,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
searchQuery={tableSearchQuery}
variableType={activeTab}
/>
</div>

View File

@@ -42,10 +42,12 @@ 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 activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeEnvTab = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envTab) || 'variables';
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.[activeEnvTab]?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.[activeEnvTab]?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', tab: activeEnvTab, query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', tab: activeEnvTab, expanded: v }));
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');

View File

@@ -1,5 +1,5 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconDeviceFloppy } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
@@ -30,6 +30,16 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
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 } }));
// Use the immediate query on a tab switch (debounced value lags and briefly
// flashes the unfiltered table).
const prevTabRef = useRef(activeTab);
const tabJustChanged = prevTabRef.current !== activeTab;
useEffect(() => {
prevTabRef.current = activeTab;
}, [activeTab]);
const tableSearchQuery = tabJustChanged ? searchQuery : debouncedSearchQuery;
const inputRef = useRef(null);
const rightContentRef = useRef(null);
@@ -205,6 +215,9 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
<ActionIcon label="Save All" onClick={handleSaveAll} data-testid="save-all-env">
<IconDeviceFloppy size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Rename" onClick={handleRenameClick} data-testid="env-rename-action">
<IconEdit size={15} strokeWidth={1.5} />
</ActionIcon>
@@ -224,9 +237,6 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
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" />
@@ -272,7 +282,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
searchQuery={tableSearchQuery}
variableType={activeTab}
/>
</div>

View File

@@ -43,11 +43,12 @@ 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 activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'global', query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'global', expanded: v }));
const activeEnvTab = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envTab) || 'variables';
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.global?.[activeEnvTab]?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.global?.[activeEnvTab]?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'global', tab: activeEnvTab, query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'global', tab: activeEnvTab, expanded: v }));
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');

View File

@@ -98,8 +98,14 @@ const initialState = {
},
systemProxyVariables: {},
envVarSearch: {
collection: { query: '', expanded: false },
global: { query: '', expanded: false }
collection: {
variables: { query: '', expanded: false },
secrets: { query: '', expanded: false }
},
global: {
variables: { query: '', expanded: false },
secrets: { query: '', expanded: false }
}
},
isCreatingCollection: false
};
@@ -236,13 +242,13 @@ export const appSlice = createSlice({
// 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;
setEnvVarSearchQuery: (state, { payload: { context, tab = 'variables', query } }) => {
if (!state.envVarSearch[context]?.[tab]) return;
state.envVarSearch[context][tab].query = query;
},
setEnvVarSearchExpanded: (state, { payload: { context, expanded } }) => {
if (!state.envVarSearch[context]) return;
state.envVarSearch[context].expanded = expanded;
setEnvVarSearchExpanded: (state, { payload: { context, tab = 'variables', expanded } }) => {
if (!state.envVarSearch[context]?.[tab]) return;
state.envVarSearch[context][tab].expanded = expanded;
},
setIsCreatingCollection: (state, action) => {
state.isCreatingCollection = action.payload;

View File

@@ -8,6 +8,7 @@ const variablesTab = (page: Page) => buildCommonLocators(page).environment.varia
const secretsTab = (page: Page) => buildCommonLocators(page).environment.secretsTab();
const varRow = (page: Page, name: string) => buildCommonLocators(page).environment.varRow(name);
const saveTab = (page: Page) => buildCommonLocators(page).environment.saveTab();
const searchInputLocator = (page: Page) => buildCommonLocators(page).environment.searchInput();
const tabDraftIcon = (page: Page) => page.locator('.request-tab.active').getByTestId('tab-draft-icon');
const searchEnv = async (page: Page, query: string) => {
@@ -19,6 +20,14 @@ const searchEnv = async (page: Page, query: string) => {
await input.fill(query);
};
const resetSearch = async (page: Page) => {
const input = page.locator('.search-input');
if ((await input.count()) === 0) return;
await input.fill('');
await input.blur();
await input.waitFor({ state: 'detached' });
};
const deleteRow = async (page: Page, name: string) => {
await varRow(page, name).locator('button:has(.icon-tabler-trash)').click();
};
@@ -174,10 +183,59 @@ test.describe('Environment Variables / Secrets tab separation', () => {
await expect(page.getByText('No results found')).toBeVisible();
});
// The search query is stored in Redux and persists across environments, so clear
// it before the test ends — otherwise it filters the next test's table to "No
// results" and the empty "Name" row never renders.
await searchEnv(page, '');
await resetSearch(page);
await variablesTab(page).click();
await resetSearch(page);
});
test('search input value persists per tab and does not leak across tabs', async ({ page, createTmpDir }) => {
await importCollection(page, collectionFile, await createTmpDir('var-secret-search-persist'), {
expectedCollectionName: 'test_collection'
});
await createEnvironment(page, 'Search Persist Env', 'collection');
await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com');
await saveTab(page).click();
await expect(page.getByText('Changes saved successfully').last()).toBeVisible();
await secretsTab(page).click();
await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345');
await saveTab(page).click();
await expect(page.getByText('Changes saved successfully').last()).toBeVisible();
const searchInput = searchInputLocator(page);
await test.step('Type a search on the Variables tab', async () => {
await variablesTab(page).click();
await searchEnv(page, 'host');
await expect(searchInput).toHaveValue('host');
});
await test.step('Switching to the Secrets tab does not carry the Variables search over', async () => {
await secretsTab(page).click();
// Secrets keeps its own (still-empty, collapsed) search state — the Variables
// query must not appear on the Secrets tab.
await expect(searchInput).toHaveCount(0);
});
await test.step('The Secrets tab keeps its own independent search', async () => {
await searchEnv(page, 'apiToken');
await expect(searchInput).toHaveValue('apiToken');
});
await test.step('Returning to the Variables tab restores its original search', async () => {
await variablesTab(page).click();
await expect(searchInput).toHaveValue('host');
await expect(varRow(page, 'host')).toBeVisible();
});
// Reset both tabs' search (clear text + collapse) so neither the query nor the
// expanded flag persists in Redux into later tests.
await resetSearch(page);
await secretsTab(page).click();
await resetSearch(page);
await variablesTab(page).click();
});
test('deleting on one tab leaves the other tab untouched', async ({ page, createTmpDir }) => {
@@ -360,10 +418,57 @@ test.describe('Global Environment Variables / Secrets tab separation', () => {
await expect(page.getByText('No results found')).toBeVisible();
});
// The search query is stored in Redux and persists across environments, so clear
// it before the test ends — otherwise it filters the next test's table to "No
// results" and the empty "Name" row never renders.
await searchEnv(page, '');
await resetSearch(page);
await variablesTab(page).click();
await resetSearch(page);
});
test('search input value persists per tab and does not leak across tabs', async ({ page, createTmpDir }) => {
await importCollection(page, collectionFile, await createTmpDir('global-var-secret-search-persist'), {
expectedCollectionName: 'test_collection'
});
await createEnvironment(page, 'Global Search Persist Env', 'global');
await addRowToActiveTab(page, 'host', 'https://echo.usebruno.com');
await saveTab(page).click();
await expect(page.getByText('Changes saved successfully').last()).toBeVisible();
await secretsTab(page).click();
await addRowToActiveTab(page, 'apiToken', 'super-secret-token-12345');
await saveTab(page).click();
await expect(page.getByText('Changes saved successfully').last()).toBeVisible();
const searchInput = searchInputLocator(page);
await test.step('Type a search on the Variables tab', async () => {
await variablesTab(page).click();
await searchEnv(page, 'host');
await expect(searchInput).toHaveValue('host');
});
await test.step('Switching to the Secrets tab does not carry the Variables search over', async () => {
await secretsTab(page).click();
// Secrets keeps its own (still-empty, collapsed) search state — the Variables
// query must not appear on the Secrets tab.
await expect(searchInput).toHaveCount(0);
});
await test.step('The Secrets tab keeps its own independent search', async () => {
await searchEnv(page, 'apiToken');
await expect(searchInput).toHaveValue('apiToken');
});
await test.step('Returning to the Variables tab restores its original search', async () => {
await variablesTab(page).click();
await expect(searchInput).toHaveValue('host');
await expect(varRow(page, 'host')).toBeVisible();
});
await resetSearch(page);
await secretsTab(page).click();
await resetSearch(page);
await variablesTab(page).click();
});
test('deleting on one tab leaves the other tab untouched', async ({ page, createTmpDir }) => {

View File

@@ -118,6 +118,7 @@ export const buildCommonLocators = (page: Page) => ({
secretsTab: () => page.getByTestId('responsive-tab-secrets'),
saveTab: () => page.getByTestId('save-env'),
saveAll: () => page.getByTestId('save-all-env'),
searchInput: () => page.getByTestId('env-search-input'),
collectionEnvTab: () => page.locator('.request-tab').filter({ hasText: /^Environments$/ }),
globalEnvTab: () => page.locator('.request-tab').filter({ hasText: /^Global Environments$/ }),
unsavedModal: {