From 4592d2c30ec737abd61a593b6d2be166a6151373 Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 7 Jul 2026 15:05:29 +0530 Subject: [PATCH] fix(app): scope env variable/secret search per tab (#8491) --- .../EnvironmentDetails/index.js | 14 +- .../EnvironmentList/index.js | 10 +- .../EnvironmentDetails/index.js | 20 ++- .../EnvironmentList/index.js | 9 +- .../src/providers/ReduxStore/slices/app.js | 22 ++-- .../variables-tab.spec.ts} | 121 ++++++++++++++++-- tests/utils/page/locators.ts | 1 + 7 files changed, 166 insertions(+), 31 deletions(-) rename tests/environments/{variable-secret-tabs/variable-secret-tabs.spec.ts => variables-tab/variables-tab.spec.ts} (78%) diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js index d93983ace..b24d5f9f4 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js @@ -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} /> diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js index 231a301c6..957124401 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js @@ -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(''); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js index c3afdc62a..0096c2ebb 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js @@ -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 {nameError && isRenaming &&
{nameError}
}
+ + + @@ -224,9 +237,6 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer onTabSelect={setActiveTab} rightContent={(
- - - {isSearchExpanded ? (
@@ -272,7 +282,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer environment={environment} setIsModified={setIsModified} collection={collection} - searchQuery={debouncedSearchQuery} + searchQuery={tableSearchQuery} variableType={activeTab} />
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js index edcc59f7b..540fe8353 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js @@ -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(''); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index e16ce0f06..fdac9a064 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -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; diff --git a/tests/environments/variable-secret-tabs/variable-secret-tabs.spec.ts b/tests/environments/variables-tab/variables-tab.spec.ts similarity index 78% rename from tests/environments/variable-secret-tabs/variable-secret-tabs.spec.ts rename to tests/environments/variables-tab/variables-tab.spec.ts index 0f8966264..263de82c7 100644 --- a/tests/environments/variable-secret-tabs/variable-secret-tabs.spec.ts +++ b/tests/environments/variables-tab/variables-tab.spec.ts @@ -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 }) => { diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index b16b3be66..887eda02e 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -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: {