diff --git a/eslint.config.js b/eslint.config.js index 18a82aa8ce7..8c9398e4206 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -142,6 +142,49 @@ module.exports = runESMImports().then(() => defineConfig([ 'react-hooks/exhaustive-deps': 'warn' } }, + { + // Prevent coarse Redux subscriptions. `state.collections.collections` and + // `state.tabs.tabs` are replaced frequently, so subscribing to either causes + // unnecessary re-renders when unrelated items or tabs change. + // + // Prefer narrow selectors from src/selectors/ when the value affects rendering. + // If the value is only needed at event time and does not affect rendering, + // read it from the store inside the event handler. + // + // Kept at `warn` during migration; change to `error` once existing violations + // have been converted. + files: ['packages/bruno-app/src/{components,providers,hooks}/**/*.{js,jsx,ts,tsx}'], + ignores: ['**/*.spec.*', '**/*.test.*'], + rules: { + 'no-restricted-syntax': [ + 'warn', + { + selector: + 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="collections"][object.type="MemberExpression"][object.property.name="collections"][object.object.type="Identifier"]', + message: + 'Do not subscribe to state.collections.collections. Use a narrow selector from src/selectors/collections (for example, selectCollectionByUid or selectItemByUid).' + }, + { + selector: + 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="collections"][object.type="Identifier"]', + message: + 'Do not subscribe to the whole state.collections slice. Select only the data the component needs from src/selectors/collections.' + }, + { + selector: + 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="tabs"][object.type="MemberExpression"][object.property.name="tabs"][object.object.type="Identifier"]', + message: + 'Do not subscribe to state.tabs.tabs. Use a narrow selector such as selectTabByUid, selectActiveTab, or makeSelectTabsForCollection.' + }, + { + selector: + 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="tabs"][object.type="Identifier"]', + message: + 'Do not subscribe to the whole state.tabs slice. Select only the data the component needs from src/selectors/tab.' + } + ] + } + }, { // It prevents lint errors when using CommonJS exports (module.exports) in Jest mocks. files: ['packages/bruno-app/src/test-utils/mocks/codemirror.js'], diff --git a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js index 6983025d26f..3bc2c4a681d 100644 --- a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js +++ b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js @@ -1,14 +1,15 @@ import React, { useMemo, useRef } from 'react'; import { useSelector } from 'react-redux'; -import { produce } from 'immer'; -import find from 'lodash/find'; import get from 'lodash/get'; import { + findCollectionByUid, findItemInCollection, findItemInCollectionByPathname, getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections'; +import { selectCollections } from 'src/selectors/collections'; +import { selectTabs, selectActiveTabUid } from 'src/selectors/tab'; import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider'; import TabPanelErrorBoundary from 'components/RequestTabPanel/TabPanelErrorBoundary'; import AppView from 'components/AppView'; @@ -25,40 +26,38 @@ const APP_CAPABLE_TAB_TYPES = new Set([ ]); const AppPreviewKeepAlive = () => { - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const _collections = useSelector((state) => state.collections.collections); + const tabs = useSelector(selectTabs); + const activeTabUid = useSelector(selectActiveTabUid); + const collections = useSelector(selectCollections); const globalEnvironments = useSelector((state) => state.globalEnvironments?.globalEnvironments); const activeGlobalEnvironmentUid = useSelector( (state) => state.globalEnvironments?.activeGlobalEnvironmentUid ); - const collections = useMemo(() => { - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ - globalEnvironments, - activeGlobalEnvironmentUid - }); - const globalEnvSecrets = getGlobalEnvironmentVariablesMasked({ - globalEnvironments, - activeGlobalEnvironmentUid - }); - return produce(_collections, (draft) => { - for (const collection of draft) { - collection.globalEnvironmentVariables = globalEnvironmentVariables; - collection.globalEnvSecrets = globalEnvSecrets; - collection.globalEnvironments = globalEnvironments; - collection.activeGlobalEnvironmentUid = activeGlobalEnvironmentUid; - } - }); - }, [_collections, globalEnvironments, activeGlobalEnvironmentUid]); - const everActiveRef = useRef(new Set()); const appTabs = useMemo(() => { const out = []; + let globals = null; + const mergedByUid = new Map(); + const withGlobals = (collection) => { + if (!mergedByUid.has(collection.uid)) { + if (!globals) { + globals = { + globalEnvironmentVariables: getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }), + globalEnvSecrets: getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid }), + globalEnvironments, + activeGlobalEnvironmentUid + }; + } + mergedByUid.set(collection.uid, { ...collection, ...globals }); + } + return mergedByUid.get(collection.uid); + }; + for (const tab of tabs) { if (tab.type && !APP_CAPABLE_TAB_TYPES.has(tab.type)) continue; - const collection = find(collections, (c) => c.uid === tab.collectionUid); + const collection = findCollectionByUid(collections, tab.collectionUid); // File-mode collections render everything through FileEditor. if (!collection || collection.fileMode) continue; let item = findItemInCollection(collection, tab.uid); @@ -68,7 +67,7 @@ const AppPreviewKeepAlive = () => { if (!item || item.partial || item.loading) continue; if (item.type === 'app') { - out.push({ tabUid: tab.uid, collection, item, kind: 'standalone' }); + out.push({ tabUid: tab.uid, collection: withGlobals(collection), item, kind: 'standalone' }); continue; } @@ -77,11 +76,11 @@ const AppPreviewKeepAlive = () => { && tab.appPreview !== false; if (appEnabled) { const code = get(itemSource, 'app.code', ''); - out.push({ tabUid: tab.uid, collection, item, kind: 'request', code }); + out.push({ tabUid: tab.uid, collection: withGlobals(collection), item, kind: 'request', code }); } } return out; - }, [tabs, collections]); + }, [tabs, collections, globalEnvironments, activeGlobalEnvironmentUid]); const validUids = new Set(appTabs.map((t) => t.tabUid)); for (const uid of [...everActiveRef.current]) { diff --git a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js index 2cb92d8ba4c..147d9ef2858 100644 --- a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js +++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js @@ -221,6 +221,19 @@ const EnvVarValueCell = ({ ); }; +const ErrorMessage = React.memo(({ id, error }) => { + if (!error) { + return null; + } + + return ( + + + + + ); +}); + const EnvironmentVariablesTable = ({ environment, inheritedEnvironmentVariables = [], @@ -371,12 +384,12 @@ const EnvironmentVariablesTable = ({ ); const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables; // `_collection` flows into every row's MultiLineEditor as the variable-resolution - // context. Without memoization, `cloneDeep(collection)` runs on every render — - // and Formik triggers a re-render on every keystroke, so a single env edit - // session can deep-clone the entire collection 100+ times. That's the - // dominant cost behind the test-budget flake. + // context. The copy exists only so the three fields below can be attached without + // writing to Redux state, so a shallow spread is enough, every consumer + // (getAllVariables, mergeVars, brunoVarInfo) reads the nested structures and never + // mutates them const _collection = useMemo(() => { - const c = collection ? cloneDeep(collection) : {}; + const c = collection ? { ...collection } : {}; c.globalEnvironmentVariables = globalEnvironmentVariables; c.globalEnvSecrets = globalEnvSecrets; c.globalEnvironments = globalEnvironments; @@ -606,32 +619,6 @@ const EnvironmentVariablesTable = ({ const duplicateSecretNames = useMemo(() => getDuplicateSecretNames(formik.values), [formik.values]); - const ErrorMessage = ({ name, index }) => { - const meta = formik.getFieldMeta(name); - const id = `error-${name}-${index}`; - - const isLastRow = index === formik.values.length - 1; - const variable = formik.values[index]; - const isEmptyRow = !variable?.name || variable.name.trim() === ''; - - if (isLastRow && isEmptyRow) { - return null; - } - - const isDuplicateSecret = variable?.secret && !isEmptyRow && duplicateSecretNames.has(variable.name.trim()); - const error = meta.error || (isDuplicateSecret ? DUPLICATE_SECRET_NAME_FIELD_ERROR : null); - - if (!error) { - return null; - } - return ( - - - - - ); - }; - const handleRemoveVar = useCallback( (id) => { const currentValues = formik.values; @@ -1073,6 +1060,12 @@ const EnvironmentVariablesTable = ({ const isLastRow = actualIndex === formik.values.length - 1; const isEmptyRow = !variable.name || variable.name.trim() === ''; const isLastEmptyRow = isLastRow && isEmptyRow; + const isDuplicateSecret + = variable.secret && !isEmptyRow && duplicateSecretNames.has(variable.name.trim()); + const rowError = isLastEmptyRow + ? null + : formik.getFieldMeta(`${actualIndex}.name`).error + || (isDuplicateSecret ? DUPLICATE_SECRET_NAME_FIELD_ERROR : null); return ( <> @@ -1121,7 +1114,10 @@ const EnvironmentVariablesTable = ({ onKeyDown={(e) => handleNameKeyDown(actualIndex, e)} /> - + diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index b7788b0713a..6eac26985ef 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -15,13 +15,16 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, inherite const environmentsDraft = collection?.environmentsDraft; const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid; - // Check for non-secret variables used in sensitive fields + const collectionItems = collection?.items; + const collectionRoot = collection?.root; + const environmentVariables = environment?.variables; + const nonSecretSensitiveVarUsageMap = useMemo(() => { const result = {}; - if (!collection || !environment?.variables) { + if (!environmentVariables) { return result; } - const nonSecretVars = environment.variables.filter((v) => v.enabled && !v.secret && v.name); + const nonSecretVars = environmentVariables.filter((v) => v.enabled && !v.secret && v.name); if (!nonSecretVars.length) { return result; } @@ -45,12 +48,12 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, inherite return item.root; }; - const collectionObj = getObjectToProcess(collection); + const collectionObj = collectionRoot; sensitiveFields.forEach((fieldPath) => { checkSensitiveField(collectionObj, fieldPath); }); - const items = flattenItems(collection.items || []); + const items = flattenItems(collectionItems || []); items.forEach((item) => { const objToProcess = getObjectToProcess(item); sensitiveFields.forEach((fieldPath) => { @@ -58,7 +61,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, inherite }); }); return result; - }, [collection, environment]); + }, [collectionItems, collectionRoot, environmentVariables]); const hasSensitiveUsage = useCallback((name) => !!nonSecretSensitiveVarUsageMap[name], [nonSecretSensitiveVarUsageMap]); diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.js b/packages/bruno-app/src/components/GlobalSearchModal/index.js index ff891cacf01..370f978f8a8 100644 --- a/packages/bruno-app/src/components/GlobalSearchModal/index.js +++ b/packages/bruno-app/src/components/GlobalSearchModal/index.js @@ -1,5 +1,7 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useSelector, useDispatch, useStore } from 'react-redux'; +import { selectCollections, selectActiveWorkspace } from 'src/selectors/collections'; +import { selectTabs } from 'src/selectors/tab'; import { IconSearch, IconX, @@ -27,23 +29,21 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { const debounceTimeoutRef = useRef(null); const dispatch = useDispatch(); - const allCollections = useSelector((state) => state.collections.collections); - const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); - const tabs = useSelector((state) => state.tabs.tabs); + const store = useStore(); + const activeWorkspace = useSelector(selectActiveWorkspace); - const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); - - const collections = useMemo(() => { + const getCollections = () => { + const allCollections = selectCollections(store.getState()); if (!activeWorkspace) return allCollections; const workspacePaths = new Set( activeWorkspace.collections?.map((wc) => normalizePath(wc.path)) || [] ); return allCollections.filter((c) => workspacePaths.has(normalizePath(c.pathname))); - }, [activeWorkspace, allCollections, workspaces]); + }; const createCollectionResults = () => { - const collectionResults = collections.map((collection) => ({ + const collectionResults = getCollections().map((collection) => ({ type: SEARCH_TYPES.COLLECTION, item: collection, name: collection.name, @@ -65,7 +65,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { results.push(DOCUMENTATION_RESULT); } - collections.forEach((collection) => { + getCollections().forEach((collection) => { // Search collection name if (searchTerms.every((term) => collection.name.toLowerCase().includes(term))) { results.push({ @@ -170,10 +170,10 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { debounceTimeoutRef.current = setTimeout(() => { performSearch(searchQuery); }, SEARCH_CONFIG.DEBOUNCE_DELAY); - }, [collections]); // Depend on collections to recreate when they change + }, [activeWorkspace]); // collections are read from the store when the search runs const expandItemPath = (result) => { - const collection = collections.find((c) => c.uid === result.collectionUid); + const collection = getCollections().find((c) => c.uid === result.collectionUid); if (!collection) return; ensureCollectionIsMounted(collection); @@ -246,7 +246,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { }; const handleResultSelection = (result) => { - const targetCollection = collections.find((c) => c.uid === result.collectionUid); + const targetCollection = getCollections().find((c) => c.uid === result.collectionUid); ensureCollectionIsMounted(targetCollection); if (result.type === SEARCH_TYPES.DOCUMENTATION) { @@ -258,7 +258,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { expandItemPath(result); if (result.type === SEARCH_TYPES.REQUEST) { - const existingTab = tabs.find((tab) => tab.uid === result.item.uid); + const existingTab = selectTabs(store.getState()).find((tab) => tab.uid === result.item.uid); if (existingTab) { dispatch(focusTab({ uid: result.item.uid })); diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index 0eb87fa7f55..65cf5fdffb1 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -264,20 +264,21 @@ class MultiLineEditor extends Component { // event loop. this.ignoreChangeEvent = true; - let variables = getAllVariables(this.props.collection, this.props.item); - if (!isEqual(variables, this.variables)) { - if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) { - this.editor.options.brunoVarInfo.variables = variables; + if (this.props.collection !== prevProps.collection || this.props.item !== prevProps.item) { + const variables = getAllVariables(this.props.collection, this.props.item); + if (!isEqual(variables, this.variables)) { + if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) { + this.editor.options.brunoVarInfo.variables = variables; + } + this.addOverlay(variables); } - this.addOverlay(variables); } - // Update collection and item when they change if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) { - if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) { + if (this.props.collection !== this.editor.options.brunoVarInfo.collection) { this.editor.options.brunoVarInfo.collection = this.props.collection; } - if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) { + if (this.props.item !== this.editor.options.brunoVarInfo.item) { this.editor.options.brunoVarInfo.item = this.props.item; } } @@ -333,7 +334,9 @@ class MultiLineEditor extends Component { this.editor.setOption('readOnly', this.props.readOnly || false); } if (this.props.mode !== prevProps.mode && this.editor) { - this.addOverlay(variables); + // `this.variables` is kept in sync by addOverlay(), so it is always the current + // variable set — no need to re-derive it just to re-apply the mode. + this.addOverlay(this.variables); } if (this.props.placeholder !== prevProps.placeholder && this.editor) { this.editor.setOption('placeholder', this.props.placeholder); diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js index 6bf215bff43..8ad60776ac0 100644 --- a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js @@ -7,9 +7,10 @@ import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; import React, { useMemo, useState, useEffect, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector, useStore } from 'react-redux'; import { queueWsMessage, ensureWsConnection } from 'utils/network/index'; -import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index'; +import { findEnvironmentInCollection } from 'utils/collections/index'; +import { selectCollectionByUid } from 'src/selectors/collections'; import toast from 'react-hot-toast'; import WSRequestBodyMode from '../BodyMode/index'; import StyledWrapper from './StyledWrapper'; @@ -47,7 +48,8 @@ export const SingleWSMessage = ({ const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); - const collections = useSelector((state) => state.collections.collections); + + const store = useStore(); const { name, content, type } = message; const displayMode = typeToMode(type); @@ -162,7 +164,7 @@ export const SingleWSMessage = ({ const onSendMessage = useCallback(async () => { try { - const col = findCollectionByUid(collections, collection.uid); + const col = selectCollectionByUid(store.getState(), collection.uid); const environment = resolveEnvironmentInheritance({ environments: col?.environments, targetEnvironment: findEnvironmentInCollection(col, col?.activeEnvironmentUid) @@ -178,7 +180,7 @@ export const SingleWSMessage = ({ } catch (err) { toast.error(err.message || 'Failed to send message'); } - }, [collections]); + }, [store, item, collection.uid, index]); return ( { const dispatch = useDispatch(); - const tabs = useSelector((state) => state.tabs.tabs); - const focusedTab = find(tabs, (t) => t.uid === tabUid); - const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type); + const isClosable = useSelector((state) => { + const tab = selectTabByUid(state, tabUid); + return !tab || !NON_CLOSABLE_TAB_TYPES.includes(tab.type); + }); const { theme } = useTheme(); const handleClose = () => { diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index fdf8e2d6e26..d168d15e81c 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -1,5 +1,4 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import find from 'lodash/find'; import get from 'lodash/get'; import toast from 'react-hot-toast'; import { useSelector, useDispatch } from 'react-redux'; @@ -23,8 +22,8 @@ import { DocExplorer } from '@usebruno/graphql-docs'; import FileEditor from 'components/FileEditor'; import StyledWrapper from './StyledWrapper'; import FolderSettings from 'components/FolderSettings'; -import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index'; -import { produce } from 'immer'; +import { makeSelectCollectionWithGlobals, selectCollectionByUid, selectActiveWorkspace } from 'src/selectors/collections'; +import { selectActiveTab, selectActiveTabUid } from 'src/selectors/tab'; import CollectionOverview from 'components/CollectionSettings/Overview'; import RequestNotLoaded from './RequestNotLoaded'; import RequestIsLoading from './RequestIsLoading'; @@ -69,13 +68,10 @@ const aiAutoCollapsedTabs = new Set(); const RequestTabPanel = () => { const dispatch = useDispatch(); - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const focusedTab = find(tabs, (t) => t.uid === activeTabUid); - const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); - const _collections = useSelector((state) => state.collections.collections); + const activeTabUid = useSelector(selectActiveTabUid); + const focusedTab = useSelector(selectActiveTab); const preferences = useSelector((state) => state.app.preferences); - const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const activeWorkspace = useSelector(selectActiveWorkspace); const resolvedMockServerInstance = useSelector((state) => { if (!focusedTab || (focusedTab.type !== 'mock-server' && focusedTab.type !== 'mock-response')) { return null; @@ -83,7 +79,6 @@ const RequestTabPanel = () => { return resolveMockServerInstance(state, focusedTab); }); - const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); const isAiSidebarDocked = useSelector((state) => state.chat.isOpen && !state.chat.isPoppedOut); @@ -102,25 +97,19 @@ const RequestTabPanel = () => { isVerticalLayoutRef.current = isVerticalLayout; }, [isVerticalLayout]); - // merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object - const collections = produce(_collections, (draft) => { - const collection = find(draft, (c) => c.uid === focusedTab?.collectionUid); + const selectCollectionWithGlobals = useMemo(makeSelectCollectionWithGlobals, []); + const collection = useSelector((state) => selectCollectionWithGlobals(state, focusedTab?.collectionUid)); - if (collection) { - // add selected global env variables to the collection object - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ - globalEnvironments, - activeGlobalEnvironmentUid - }); - const globalEnvSecrets = getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid }); - collection.globalEnvironmentVariables = globalEnvironmentVariables; - collection.globalEnvSecrets = globalEnvSecrets; - collection.globalEnvironments = globalEnvironments; - collection.activeGlobalEnvironmentUid = activeGlobalEnvironmentUid; - } - }); - - const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid); + // Mock-server tabs may point at a collection other than the focused one. + const mockInstanceCollectionUid = resolvedMockServerInstance + ? (resolvedMockServerInstance.sourceType === 'collection' + ? resolvedMockServerInstance.collectionUid + : focusedTab?.collectionUid) + : undefined; + const mockInstanceCollectionRaw = useSelector((state) => selectCollectionByUid(state, mockInstanceCollectionUid)); + const mockInstanceCollection = mockInstanceCollectionUid && mockInstanceCollectionUid === focusedTab?.collectionUid + ? collection + : mockInstanceCollectionRaw ?? null; const isItemsLoading = useMemo(() => { return collection?.mountStatus === 'mounting' || areItemsLoading(collection); @@ -458,11 +447,7 @@ const RequestTabPanel = () => { ); } - const instanceCollection = instance.sourceType === 'collection' - ? find(collections, (c) => c.uid === instance.collectionUid) - : (focusedTab.collectionUid ? find(collections, (c) => c.uid === focusedTab.collectionUid) : null); - - return ; + return ; } if (focusedTab.type === 'mock-response') { @@ -475,14 +460,10 @@ const RequestTabPanel = () => { ); } - const instanceCollection = instance.sourceType === 'collection' - ? find(collections, (c) => c.uid === instance.collectionUid) - : (focusedTab.collectionUid ? find(collections, (c) => c.uid === focusedTab.collectionUid) : null); - return ( ); diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index 535ec7c118e..7f516398f1c 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector, useStore, shallowEqual } from 'react-redux'; import { IconCategory, IconBox, @@ -29,8 +29,10 @@ import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collection import { toggleAiSidebar } from 'providers/ReduxStore/slices/chat'; import { showMigrateToYmlModal } from 'providers/ReduxStore/slices/collection-migration'; import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections'; -import find from 'lodash/find'; import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import { selectCollections, selectActiveWorkspace } from 'src/selectors/collections'; +import { selectActiveTab, selectActiveTabUid, selectTabs } from 'src/selectors/tab'; import { addTab, focusTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs'; import { uuid } from 'utils/common'; import toast from 'react-hot-toast'; @@ -64,23 +66,42 @@ const readDismissedCollections = () => { const CollectionHeader = ({ collection, isScratchCollection }) => { const dispatch = useDispatch(); - const workspaces = useSelector((state) => state.workspaces.workspaces); + const store = useStore(); const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); - const collections = useSelector((state) => state.collections.collections); - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const activeTabUid = useSelector(selectActiveTabUid); + const focusedTab = useSelector(selectActiveTab); const preferences = useSelector((state) => state.app.preferences); const isAiEnabled = get(preferences, 'ai.enabled', false); const isAiSidebarOpen = useSelector((state) => state.chat.isOpen); // Get the current active workspace - const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const currentWorkspace = useSelector(selectActiveWorkspace); + + const mountedCollections = useSelector((state) => { + const { workspaces } = state.workspaces; + const workspaceCollectionPaths = (selectActiveWorkspace(state)?.collections || []).map((wc) => normalizePath(wc.path)); + return selectCollections(state) + .filter((c) => { + if (c.mountStatus !== 'mounted') return false; + if (workspaces.some((w) => w.scratchCollectionUid === c.uid)) return false; + return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === wcPath); + }) + .map((c) => ({ uid: c.uid, name: c.name })); + }, isEqual); + + // Open-tab counts per collection, for the badges in the switcher. + const tabCountsByCollection = useSelector((state) => { + const counts = {}; + for (const t of selectTabs(state)) { + counts[t.collectionUid] = (counts[t.collectionUid] || 0) + 1; + } + return counts; + }, shallowEqual); const gitRootPath = collection?.git?.gitRootPath; const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER); const mockServerInstances = useSelector((state) => getMockServerInstances(state, activeWorkspaceUid)); // Active request (used by the Request / App / File view-mode toggle) - const focusedTab = find(tabs, (t) => t.uid === activeTabUid); const activeItem = focusedTab && collection ? (findItemInCollection(collection, activeTabUid) || (focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null)) @@ -206,22 +227,11 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { const hasOpenApiUpdates = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.hasUpdates; const hasOpenApiError = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.error; - // Get mounted collections for the current workspace (excluding scratch collections) - const mountedCollections = collections.filter((c) => { - if (c.mountStatus !== 'mounted') return false; - - const isScratch = workspaces.some((w) => w.scratchCollectionUid === c.uid); - if (isScratch) return false; - - const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || []; - return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === normalizePath(wcPath)); - }); - // Count tabs for the current collection - const tabCount = tabs.filter((t) => t.collectionUid === collection.uid).length; + const tabCount = tabCountsByCollection[collection.uid] || 0; // Get tab count for a given collection uid - const getTabCount = (collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid).length; + const getTabCount = (collectionUid) => tabCountsByCollection[collectionUid] || 0; // Get tab count for workspace (scratch collection) const workspaceTabCount = currentWorkspace?.scratchCollectionUid @@ -247,7 +257,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { switcherRef.current?.hide(); if (!targetCollection?.uid) return; - const existingTab = tabs.find((t) => t.collectionUid === targetCollection.uid); + const existingTab = selectTabs(store.getState()).find((t) => t.collectionUid === targetCollection.uid); if (existingTab) { dispatch(focusTab({ uid: existingTab.uid })); } else { diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index 38e6bb13280..df6912668f9 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -1,6 +1,4 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import find from 'lodash/find'; -import filter from 'lodash/filter'; import classnames from 'classnames'; import { IconChevronRight, IconChevronLeft } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; @@ -12,6 +10,8 @@ import StyledWrapper from './StyledWrapper'; import DraggableTab from './DraggableTab'; import CreateTransientRequest from 'components/CreateTransientRequest'; import ActionIcon from 'ui/ActionIcon/index'; +import { selectCollectionByUid } from 'src/selectors/collections'; +import { selectActiveTab, selectActiveTabUid, makeSelectTabsForCollection } from 'src/selectors/tab'; const RequestTabs = () => { const dispatch = useDispatch(); @@ -21,13 +21,21 @@ const RequestTabs = () => { const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); const [tabOverflowStates, setTabOverflowStates] = useState({}); const [showChevrons, setShowChevrons] = useState(false); - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const collections = useSelector((state) => state.collections.collections); + const activeTabUid = useSelector(selectActiveTabUid); + const activeTab = useSelector(selectActiveTab); + // Only the active collection: an edit in any other collection must not + // re-render the tab strip. + const activeCollection = useSelector((state) => selectCollectionByUid(state, activeTab?.collectionUid)); + // Memoized on the tabs reference, so the array is stable between tab actions. + const selectTabsForCollection = useMemo(makeSelectTabsForCollection, []); + const collectionRequestTabs = useSelector((state) => selectTabsForCollection(state, activeTab?.collectionUid)); + const totalTabsCount = useSelector((state) => state.tabs.tabs.length); const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth); const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed); const screenWidth = useSelector((state) => state.app.screenWidth); - const workspaces = useSelector((state) => state.workspaces.workspaces); + const isScratchCollection = useSelector((state) => + activeCollection ? state.workspaces.workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false + ); const createSetHasOverflow = useCallback((tabUid) => { return (hasOverflow) => { @@ -43,14 +51,6 @@ const RequestTabs = () => { }; }, []); - const activeTab = find(tabs, (t) => t.uid === activeTabUid); - const activeCollection = find(collections, (c) => c?.uid === activeTab?.collectionUid); - const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid); - - const isScratchCollection = useMemo(() => { - return activeCollection ? workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false; - }, [workspaces, activeCollection]); - useEffect(() => { if (!activeTabUid || !activeTab) return; @@ -73,7 +73,7 @@ const RequestTabs = () => { const getTabClassname = (tab, index) => { return classnames('request-tab select-none', { 'active': tab.uid === activeTabUid, - 'last-tab': tabs && tabs.length && index === tabs.length - 1, + 'last-tab': totalTabsCount && index === totalTabsCount - 1, 'has-overflow': tabOverflowStates[tab.uid] }); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/StyledWrapper.js similarity index 92% rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/StyledWrapper.js index 75608c802f9..b723d8762f1 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/StyledWrapper.js @@ -162,19 +162,6 @@ const Wrapper = styled.div` } } - .empty-folder-message { - display: flex; - align-items: center; - height: 1.6rem; - font-size: ${(props) => props.theme.font.size.sm}; - color: ${(props) => props.theme.sidebar.muted}; - - .add-request-link { - color: ${(props) => props.theme.textLink}; - cursor: pointer; - } - } - &.is-sidebar-dragging .collection-item-name { cursor: inherit; } diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx similarity index 81% rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index 0d43356097e..2e2a8f13b30 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -1,6 +1,5 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import range from 'lodash/range'; -import filter from 'lodash/filter'; import classnames from 'classnames'; import { useDrag, useDrop } from 'react-dnd'; import { getEmptyImage } from 'react-dnd-html5-backend'; @@ -22,40 +21,37 @@ import { IconAppWindow, IconEyeOff } from '@tabler/icons'; -import { useSelector, useDispatch, useStore } from 'react-redux'; +import { useSelector, useDispatch, useStore, shallowEqual } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { handleMultipleCollectionItemsDrop, sendRequest, showInFolder, pasteItem, saveRequest, cloneItem } from 'providers/ReduxStore/slices/collections/actions'; import { sanitizeName } from 'utils/common/regex'; import { formatIpcError } from 'utils/common/error'; -import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections'; +import { toggleCollectionItem, toggleRequestExamples, addResponseExample } from 'providers/ReduxStore/slices/collections'; import { uuid } from 'utils/common'; import { copyRequest, setFocusedSidebarPath, insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; import NewApp from 'components/Sidebar/NewApp'; -import RenameCollectionItem from './RenameCollectionItem'; -import CloneCollectionItem from './CloneCollectionItem'; -import DeleteCollectionItems from './DeleteCollectionItems'; -import IgnoreCollectionItem from './IgnoreCollectionItem'; -import RunCollectionItem from './RunCollectionItem'; -import GenerateCodeItem from './GenerateCodeItem'; +import RenameCollectionItem from '../RenameCollectionItem'; +import DeleteCollectionItems from '../DeleteCollectionItems'; +import IgnoreCollectionItem from '../IgnoreCollectionItem'; +import RunCollectionItem from '../RunCollectionItem'; +import GenerateCodeItem from '../GenerateCodeItem'; import { isItemARequest, isItemAFolder, scrollToTheActiveTab } from 'utils/tabs'; import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; import { getDefaultRequestPaneTab, getItemTypeLabel } from 'utils/collections'; import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; import NetworkError from 'components/ResponsePane/NetworkError/index'; -import CollectionItemInfo from './CollectionItemInfo/index'; -import CollectionItemIcon from './CollectionItemIcon'; -import ExampleItem from './ExampleItem'; +import CollectionItemInfo from '../CollectionItemInfo/index'; +import CollectionItemIcon from '../CollectionItemIcon'; import ExampleIcon from 'components/Icons/ExampleIcon'; import { getTabUidForItem as getTabUidForItemSelector, isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; -import { isEqual } from 'lodash'; -import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; +import { selectCollectionByUid } from 'src/selectors/collections'; import { canCollectionItemBeDropped, determineCollectionItemDrop, @@ -63,7 +59,6 @@ import { findParentItemInCollection, getSortedDraggedItems } from 'utils/collections/index'; -import { sortByNameThenSequence } from 'utils/common/index'; import { getRevealInFolderLabel } from 'utils/common/platform'; import CreateExampleModal from 'components/ResponseExample/CreateExampleModal'; import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; @@ -74,25 +69,34 @@ import useKeybinding from 'hooks/useKeybinding'; import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections/index'; -const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu, isMultiDragDisabled, multiDragItems: multiDragItemsForSelection }) => { +const CollectionItemRow = ({ + item, + depth, + collectionUid, + collectionPathname, + searchText, + openBulkMenu, + children, + isMultiDragDisabled, + multiDragItems: multiDragItemsForSelection +}) => { const { dropdownContainerRef } = useSidebarAccordion(); - const selectorInput = { - itemUid: item.uid, - itemPathname: item.pathname, - collectionUid - }; - - const _isTabForItemActiveSelector = isTabForItemActiveSelector(selectorInput); - const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual); - - const _isTabForItemPresentSelector = isTabForItemPresentSelector(selectorInput); - const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual); - - const _tabUidForItemSelector = getTabUidForItemSelector(selectorInput); - const tabUidForItem = useSelector(_tabUidForItemSelector, isEqual); + const { isTabForItemActive, isTabForItemPresent, tabUidForItem } = useSelector( + useMemo(() => { + const selectorInput = { itemUid: item.uid, itemPathname: item.pathname, collectionUid }; + const selectActive = isTabForItemActiveSelector(selectorInput); + const selectPresent = isTabForItemPresentSelector(selectorInput); + const selectTabUid = getTabUidForItemSelector(selectorInput); + return (state) => ({ + isTabForItemActive: selectActive(state), + isTabForItemPresent: selectPresent(state), + tabUidForItem: selectTabUid(state) + }); + }, [item.uid, item.pathname, collectionUid]), + shallowEqual + ); const isSidebarDragging = useSelector((state) => state.app.isDragging); - const collection = useSelector((state) => state.collections.collections.find((c) => c.uid === collectionUid)); const store = useStore(); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); @@ -122,7 +126,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o const [newAppModalOpen, setNewAppModalOpen] = useState(false); const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); - const [examplesExpanded, setExamplesExpanded] = useState(false); + const examplesExpanded = Boolean(item.examplesExpanded); const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; @@ -178,17 +182,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o } }); - // Auto-scroll to show this item when its tab becomes active - useEffect(() => { - if (isTabForItemActive && ref.current) { - try { - ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } catch (err) { - // ignore scroll errors (some environments may not support smooth scrolling) - } - } - }, [isTabForItemActive]); - const resolveDropFromMonitor = (monitor) => { return determineCollectionItemDrop({ item, @@ -386,7 +379,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o const handleExamplesCollapse = (e) => { e.stopPropagation(); e.preventDefault(); - setExamplesExpanded(!examplesExpanded); + dispatch(toggleRequestExamples({ collectionUid, itemUid: item.uid })); }; // prevent the parent's double-click handler from firing @@ -408,7 +401,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o menuDropdownRef.current?.show(); }; - const indents = range(item.depth); + const indents = range(depth); // Build menu items for MenuDropdown const buildMenuItems = () => { @@ -583,11 +576,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o dispatch(makeTabPermanent({ uid: tabUidForItem || item.uid })); }; - // Sort items by their "seq" property. - const sortItemsBySequence = (items = []) => { - return items.sort((a, b) => a.seq - b.seq); - }; - const handleShowInFolder = () => { dispatch(showInFolder(item.pathname)).catch((error) => { console.error('Error opening the folder', error); @@ -640,14 +628,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o setCreateExampleModalOpen(false); }; - const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient)); - const appItems = sortItemsBySequence(filter(item.items, (i) => i.type === 'app' && !i.isTransient)); - const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient)); - const showEmptyFolderMessage - = isFolder && !hasSearchText && !folderItems?.length && !appItems?.length && !requestItems?.length; - - const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid }); - const handleGenerateCode = () => { if ( (item?.request?.url !== '') @@ -694,6 +674,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o // Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder let targetFolderUid = item.uid; if (!isFolder) { + const collection = selectCollectionByUid(store.getState(), collectionUid); const parentFolder = findParentItemInCollection(collection, item.uid); targetFolderUid = parentFolder ? parentFolder.uid : null; } @@ -800,7 +781,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o data-testid="folder-chevron" /> - ) : hasExamples ? ( + ) : hasExamples && !hasSearchText ? ( - {!itemIsCollapsed ? ( -
- {folderItems && folderItems.length - ? folderItems.map((i) => { - return ; - }) - : null} - {appItems && appItems.length - ? appItems.map((i) => { - return ; - }) - : null} - {requestItems && requestItems.length - ? requestItems.map((i) => { - return ; - }) - : null} - {showEmptyFolderMessage ? ( -
- {range(item.depth + 1).map((i) => ( -
-   -
- ))} -
- - - -
-
- ) : null} -
- ) : null} - - {/* Show examples when expanded (only for HTTP requests) */} - {isItemARequest(item) && item.type === 'http-request' && examplesExpanded && hasExamples && ( -
- {(item.examples || []).map((example, index) => { - return ( - - ); - })} -
- )} + + {children}
); }; -export default React.memo(CollectionItem); +export default React.memo(CollectionItemRow); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js index d720d8a49fc..02d9189699d 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js @@ -2,7 +2,11 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` position: relative; - + + .indent-block { + border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; + } + .menu-icon { color: ${(props) => props.theme.sidebar.dropdownIcon.color}; visibility: hidden; @@ -17,7 +21,7 @@ const StyledWrapper = styled.div` } } - .collection-item-name { + &.collection-item-name { height: 1.6rem; cursor: pointer; user-select: none; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js index 1f020d32d4c..f72758c3648 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js @@ -13,7 +13,6 @@ import ExampleIcon from 'components/Icons/ExampleIcon'; import range from 'lodash/range'; import classnames from 'classnames'; import MenuDropdown from 'ui/MenuDropdown'; -import ActionIcon from 'ui/ActionIcon'; import Modal from 'components/Modal'; import DeleteResponseExampleModal from './DeleteResponseExampleModal'; import GenerateCodeItem from '../GenerateCodeItem'; @@ -21,7 +20,7 @@ import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; -const ExampleItem = ({ example, item, collection }) => { +const ExampleItem = ({ example, item, collection, depth }) => { const { dropdownContainerRef } = useSidebarAccordion(); const dispatch = useDispatch(); const activeTabUid = useSelector((state) => state.tabs?.activeTabUid); @@ -33,8 +32,9 @@ const ExampleItem = ({ example, item, collection }) => { const exampleRef = useRef(null); const menuDropdownRef = useRef(null); - // Calculate indentation: item depth + 1 for examples - const indents = range((item.depth || 0) + 1); + // Indentation comes from the flattener, which already emits example rows one level + // deeper than their parent request. + const indents = range(depth); const handleExampleClick = () => { const exampleIndex = item?.examples?.findIndex((ex) => ex.uid === example.uid); @@ -64,16 +64,6 @@ const ExampleItem = ({ example, item, collection }) => { setEditName(example.name); }, [example.name]); - useEffect(() => { - if (isExampleActive && exampleRef.current) { - try { - exampleRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } catch (err) { - // ignore scroll errors - } - } - }, [isExampleActive]); - const handleClone = async () => { // Calculate the index where the cloned example will be saved // It will be at the end of the examples array diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/StyledWrapper.js similarity index 90% rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/StyledWrapper.js index 5f23cec174a..abe32315bb7 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/StyledWrapper.js @@ -96,19 +96,6 @@ const Wrapper = styled.div` .indent-block { border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; } - - .empty-collection-message { - display: flex; - align-items: center; - height: 1.6rem; - font-size: ${(props) => props.theme.font.size.sm}; - color: ${(props) => props.theme.sidebar.muted}; - - .add-request-link { - color: ${(props) => props.theme.textLink}; - cursor: pointer; - } - } `; export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx similarity index 79% rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx index 3b3136b6be8..457792f6026 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx @@ -1,7 +1,6 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import classnames from 'classnames'; import { uuid } from 'utils/common'; -import filter from 'lodash/filter'; import { useDrop, useDrag } from 'react-dnd'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { @@ -28,45 +27,38 @@ import { import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; import { toggleCollection, collapseFullCollection, clearSidebarSelection } from 'providers/ReduxStore/slices/collections'; import { mountCollection, moveCollectionAndPersist, handleMultipleCollectionItemsDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector, useStore } from 'react-redux'; import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; import NewApp from 'components/Sidebar/NewApp'; -import CollectionItem from './CollectionItem'; -import RemoveCollections from './RemoveCollections'; -import MoveToWorkspace from './MoveToWorkspace'; +import RemoveCollections from '../RemoveCollections'; +import MoveToWorkspace from '../MoveToWorkspace'; import { isPathExternalToBasePath } from 'utils/common/path'; import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search'; -import { isItemAFolder, isItemARequest, getSortedDraggedItems } from 'utils/collections'; +import { getSortedDraggedItems } from 'utils/collections'; import { isTabForItemActive } from 'src/selectors/tab'; -import RenameCollection from './RenameCollection'; +import RenameCollection from '../RenameCollection'; import StyledWrapper from './StyledWrapper'; -import CloneCollection from './CloneCollection'; +import CloneCollection from '../CloneCollection'; import { scrollToTheActiveTab } from 'utils/tabs'; import ShareCollection from 'components/ShareCollection/index'; -import GenerateDocumentation from './GenerateDocumentation'; -import { sortByNameThenSequence } from 'utils/common/index'; +import GenerateDocumentation from '../GenerateDocumentation'; import { getRevealInFolderLabel } from 'utils/common/platform'; import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; import ActionIcon from 'ui/ActionIcon'; import MenuDropdown from 'ui/MenuDropdown'; import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; -import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; import useKeybinding from 'hooks/useKeybinding'; import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; import StatusBadge from 'ui/StatusBadge'; import CreateMockServerModal from 'components/MockServer/CreateMockServerModal'; import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; -// Delay before showing empty collection state (ms) -// This prevents flicker from race condition between loading state and item batch updates -const EMPTY_STATE_DELAY_MS = 300; - -const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, multiDragCollections, multiDragItems: multiDragItemsForSelection }) => { +const CollectionRow = ({ collection, searchText, openBulkMenu, children, isMultiDragDisabled, multiDragCollections }) => { const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER); const { dropdownContainerRef } = useSidebarAccordion(); const [showNewFolderModal, setShowNewFolderModal] = useState(false); @@ -81,15 +73,12 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, const [showCreateMockServerModal, setShowCreateMockServerModal] = useState(false); const [dropType, setDropType] = useState(null); const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); - const [showEmptyState, setShowEmptyState] = useState(false); const dispatch = useDispatch(); const isLoading = collection.isLoading; const collectionRef = useRef(null); - // Only count persisted requests and folders; transients and file items - // (bruno.json, .js scripts) don't affect empty state - const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i) || i.type === 'app')).length || 0; - const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid })); + const selectIsCollectionFocused = useMemo(() => isTabForItemActive({ itemUid: collection.uid }), [collection.uid]); + const isCollectionFocused = useSelector(selectIsCollectionFocused); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); const isSelected = selectedSidebarUids.includes(collection.uid); @@ -103,7 +92,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, ); const workspaces = useSelector((state) => state.workspaces.workspaces); const collectionSortOrder = useSelector((state) => state.collections.collectionSortOrder); - const allCollections = useSelector((state) => state.collections.collections); + const store = useStore(); const isMoveToWorkspaceVisible = isPathExternalToBasePath(activeWorkspace?.pathname, collection.pathname); const isDragDisabled = isMultiSelected && isMultiDragDisabled; @@ -327,7 +316,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, const draggedItems = getSortedDraggedItems({ draggedItem, - allCollections, + allCollections: store.getState().collections.collections, workspaces, activeWorkspace, collectionSortOrder, @@ -346,7 +335,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, } else { const draggedItems = getSortedDraggedItems({ draggedItem, - allCollections, + allCollections: store.getState().collections.collections, workspaces, activeWorkspace, collectionSortOrder, @@ -377,31 +366,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, drag(drop(collectionRef)); dragPreview(getEmptyImage(), { captureDraggingState: true }); - useEffect(() => { - if (isCollectionFocused && collectionRef.current) { - try { - collectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } catch (err) { - // ignore scroll errors - } - } - }, [isCollectionFocused]); - - // Debounce showing empty state to prevent flicker - // Race condition: isLoading can become false before items batch arrives from IPC - useEffect(() => { - const isMounted = collection.mountStatus === 'mounted'; - const hasItems = itemCount > 0; - - if (hasItems || isLoading || !isMounted) { - setShowEmptyState(false); - return; - } - - const timer = setTimeout(() => setShowEmptyState(true), EMPTY_STATE_DELAY_MS); - return () => clearTimeout(timer); - }, [itemCount, isLoading, collection.mountStatus]); - if (searchText && searchText.length) { if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) { return null; @@ -420,18 +384,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, } ); - // we need to sort request items by seq property - const sortItemsBySequence = (items = []) => { - return items.sort((a, b) => a.seq - b.seq); - }; - - const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient)); - const appItems = sortItemsBySequence(filter(collection.items, (i) => i.type === 'app' && !i.isTransient)); - const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient)); - const showEmptyCollectionMessage = showEmptyState && !hasSearchText; - - const emptyStateMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: null }); - const menuItems = [ { id: 'new-request', @@ -582,7 +534,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, ]; return ( - + {showNewRequestModal && setShowNewRequestModal(false)} />} {showNewFolderModal && setShowNewFolderModal(false)} />} {showNewAppModal && setShowNewAppModal(false)} />} @@ -660,41 +612,9 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, )} -
- {!collectionIsCollapsed ? ( -
- {folderItems?.map?.((i) => { - return ; - })} - {appItems?.map?.((i) => { - return ; - })} - {requestItems?.map?.((i) => { - return ; - })} - {showEmptyCollectionMessage ? ( -
-
-   -
-
- - - -
-
- ) : null} -
- ) : null} -
+ {children}
); }; -export default Collection; +export default CollectionRow; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js index 525109b3709..ffc2ec36654 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js @@ -3,9 +3,10 @@ import Modal from 'components/Modal/index'; import { IconFiles } from '@tabler/icons'; import { useSelector } from 'react-redux'; import StyledWrapper from './StyledWrapper'; +import { selectCollections } from 'src/selectors/collections'; const SelectCollection = ({ onClose, onSelect, title }) => { - const { collections } = useSelector((state) => state.collections); + const collections = useSelector(selectCollections); return ( diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/StyledWrapper.js new file mode 100644 index 00000000000..4ca8a931ce2 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/StyledWrapper.js @@ -0,0 +1,22 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + .empty-cta-message { + display: flex; + align-items: center; + height: 1.6rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.sidebar.muted}; + + .add-request-link { + color: ${(props) => props.theme.textLink}; + cursor: pointer; + } + } + + .indent-block { + border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/index.jsx new file mode 100644 index 00000000000..d67a78b569d --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/index.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import range from 'lodash/range'; +import { useDispatch } from 'react-redux'; +import MenuDropdown from 'ui/MenuDropdown'; +import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; +import StyledWrapper from './StyledWrapper'; + +const EmptyCtaRow = ({ collection, itemUid = null, depth = 1 }) => { + const { dropdownContainerRef } = useSidebarAccordion(); + const dispatch = useDispatch(); + + if (!collection) return null; + + const menuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid }); + const testId = itemUid ? 'add-request-cta-folder' : 'add-request-cta'; + + return ( + +
+ {range(depth).map((i) => ( +
+   +
+ ))} +
+ + + +
+
+
+ ); +}; + +export default React.memo(EmptyCtaRow); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx new file mode 100644 index 00000000000..515b5946c80 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -0,0 +1,119 @@ +import React from 'react'; +import CollectionRow from '../Collection/CollectionRow'; +import CollectionItemRow from '../Collection/CollectionItem/CollectionItemRow'; +import GitRemoteCollectionRow from '../GitRemoteCollectionRow'; +import ExampleItem from '../Collection/CollectionItem/ExampleItem'; +import EmptyCtaRow from './EmptyCtaRow'; + +const resolveRowObject = ({ row, itemsByUid, collectionsByUid, ghostsByPath }) => { + switch (row.kind) { + case 'collection': + case 'empty-cta': + // collection header and collection-root empty-cta both key off collectionUid + return collectionsByUid.get(row.collectionUid); + case 'folder': + case 'app': + case 'request': + case 'example': + // example rows resolve to their parent request. + return itemsByUid.get(row.itemUid); + case 'ghost': + return ghostsByPath.get(row.collectionPathname); + default: + return undefined; + } +}; + +const renderRow = (props) => { + const { row, searchText, openBulkMenu, collectionsByUid, isMultiDragDisabled, multiDragCollections, multiDragItems } = props; + const resolved = resolveRowObject(props); + + switch (row.kind) { + case 'collection': { + if (!resolved) return null; + return ( + + ); + } + case 'folder': + case 'app': + case 'request': { + if (!resolved) return null; + return ( + + ); + } + case 'empty-cta': { + return ; + } + case 'ghost': { + if (!resolved) return null; + return ; + } + case 'example': { + const item = resolved; + const collection = collectionsByUid.get(row.collectionUid); + const example = item?.examples?.[row.exampleIndex]; + if (!item || !collection || !example) return null; + return ; + } + default: + return null; + } +}; + +const SidebarRow = (props) => { + const { row } = props; + const inner = renderRow(props); + if (inner === null) return null; + return ( +
+ {inner} +
+ ); +}; + +// Compare row values instead of object identity because flattening creates new row objects +// on every rebuild. +const areEqual = (prev, next) => { + const a = prev.row; + const b = next.row; + return ( + a.kind === b.kind + && a.id === b.id + && a.depth === b.depth + && a.itemUid === b.itemUid + && a.collectionUid === b.collectionUid + && a.collectionId === b.collectionId + && a.parentName === b.parentName + && a.collectionPathname === b.collectionPathname + && a.exampleIndex === b.exampleIndex + && prev.searchText === next.searchText + && prev.isMultiDragDisabled === next.isMultiDragDisabled + && prev.multiDragCollections === next.multiDragCollections + && prev.multiDragItems === next.multiDragItems + && resolveRowObject(prev) === resolveRowObject(next) + ); +}; + +export default React.memo(SidebarRow, areEqual); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index 0235625c563..a8b9dcdc674 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -1,22 +1,30 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import Collection from './Collection'; -import GitRemoteCollectionRow from './GitRemoteCollectionRow'; +import { Virtuoso } from 'react-virtuoso'; import StyledWrapper from './StyledWrapper'; import CreateOrOpenCollection from './CreateOrOpenCollection'; import CollectionSearch from './CollectionSearch/index'; import InlineCollectionCreator from './InlineCollectionCreator'; +import SidebarRow from './SidebarRow'; import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections'; import { buildSidebarEntries, getSelectionInfo } from 'utils/collections/index'; +import { flattenSidebarTree, buildIndexes } from 'utils/collections/flattenSidebarTree'; import { CollectionItemDragPreview } from './Collection/CollectionItem/CollectionItemDragPreview'; import useBulkActionsMenu from 'hooks/useBulkActionsMenu'; import BulkActionsMenu from 'components/Sidebar/Collections/BulkActionsMenu'; +import { selectCollections, selectCollectionSortOrder, selectSelectedSidebarUids } from 'src/selectors/collections'; +import { selectActiveTabUid } from 'src/selectors/tab'; const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => { const [searchText, setSearchText] = useState(''); - const { collections, collectionSortOrder, selectedSidebarUids } = useSelector((state) => state.collections); - const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const collections = useSelector(selectCollections); + const collectionSortOrder = useSelector(selectCollectionSortOrder); + const selectedSidebarUids = useSelector(selectSelectedSidebarUids); + const workspaces = useSelector((state) => state.workspaces.workspaces); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const activeTabUid = useSelector(selectActiveTabUid); const dispatch = useDispatch(); + const virtuosoRef = useRef(null); const { openBulkMenu, menuProps } = useBulkActionsMenu(); @@ -31,6 +39,22 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis [activeWorkspace, collections, workspaces, collectionSortOrder] ); + // Flatten the tree into ordered rows. itemsByUid / collectionsByUid resolve a row's live object. + const { rows, itemsByUid, collectionsByUid } = useMemo( + () => flattenSidebarTree(sidebarEntries, { searchText }), + [sidebarEntries, searchText] + ); + + // Ghost rows carry only path/name. GitRemoteCollectionRow needs the full entry (for `remote`). + const ghostsByPath = useMemo(() => { + const map = new Map(); + for (const entry of sidebarEntries) { + if (entry.kind === 'ghost' && entry.entry?.path) map.set(entry.entry.path, entry.entry); + } + return map; + }, [sidebarEntries]); + + // Multi-select drag context, computed once for the whole list and threaded to rows via SidebarRow. const selectionInfo = useMemo( () => (selectedSidebarUids.length > 1 ? getSelectionInfo({ collections, selectedUids: selectedSidebarUids }) : null), [collections, selectedSidebarUids] @@ -48,10 +72,27 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis return selectionInfo.effectiveSelection.map((entry) => ({ ...entry.item, sourceCollectionUid: entry.collectionUid })); }, [selectionInfo]); + const { rowIndexByItemUid, rowIndexByCollectionUid } = useMemo(() => buildIndexes(rows), [rows]); + + // Resolve the active tab's row index (item rows first, then collection headers). + const rowIndex = rowIndexByItemUid.get(activeTabUid); + const activeRowIndex = activeTabUid !== null + ? (rowIndex ?? rowIndexByCollectionUid.get(activeTabUid) ?? null) + : null; + + useEffect(() => { + if (activeRowIndex === null) return; + virtuosoRef.current?.scrollIntoView({ index: activeRowIndex, behavior: 'smooth' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTabUid]); + + // Clear multi-selection only when clicking the bare scroller background. + // The `contains` guard ignores events propagated from portaled menus/modals in . + // The `[data-sidebar-row]` check covers all row types and inline menus/modals rendered within a row. const handleContainerClick = (e) => { - if (e.currentTarget === e.target) { - dispatch(clearSidebarSelection()); - } + if (!e.currentTarget.contains(e.target)) return; + if (e.target.closest('[data-sidebar-row]')) return; + dispatch(clearSidebarSelection()); }; if (!sidebarEntries.length) { @@ -75,33 +116,40 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis )} + {isCreatingCollection && ( + + )} +
- {isCreatingCollection && ( - - )} - {sidebarEntries.map((entry) => { - if (entry.kind === 'loaded') { - return ( - - ); - } - return ; - })} + row.id} + defaultItemHeight={26} + increaseViewportBy={{ top: 400, bottom: 600 }} + itemContent={(_, row) => ( + + )} + />
diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 026e68a541b..d526c3682cb 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -1,7 +1,7 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import toast from 'react-hot-toast'; import get from 'lodash/get'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector, useStore } from 'react-redux'; import { IconArrowsSort, IconDotsVertical, @@ -21,6 +21,7 @@ import { sortCollections } from 'providers/ReduxStore/slices/collections/index'; import { savePreferences, setIsCreatingCollection, setIsOpeningCollection, toggleSidebarSearch } from 'providers/ReduxStore/slices/app'; import { normalizePath } from 'utils/common/path'; import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections'; +import { selectCollections, selectCollectionSortOrder, selectActiveWorkspace } from 'src/selectors/collections'; import { sanitizeName } from 'utils/common/regex'; import filter from 'lodash/filter'; @@ -44,12 +45,11 @@ const CollectionsSection = () => { const dispatch = useDispatch(); const showSearch = useSelector((state) => state.app.showSidebarSearch); - const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); - const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const activeWorkspace = useSelector(selectActiveWorkspace); - const { collections } = useSelector((state) => state.collections); - const { collectionSortOrder } = useSelector((state) => state.collections); - const { isCreatingCollection } = useSelector((state) => state.app); + const store = useStore(); + const collectionSortOrder = useSelector(selectCollectionSortOrder); + const isCreatingCollection = useSelector((state) => state.app.isCreatingCollection); const preferences = useSelector((state) => state.app.preferences); const [collectionsToClose, setCollectionsToClose] = useState([]); @@ -88,16 +88,18 @@ const CollectionsSection = () => { }); }; - const workspaceCollections = useMemo(() => { + const getWorkspaceCollections = () => { if (!activeWorkspace) return []; + const state = store.getState(); + const { workspaces } = state.workspaces; - return collections.filter((c) => { + return selectCollections(state).filter((c) => { if (isScratchCollection(c, workspaces)) { return false; } return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)); }); - }, [activeWorkspace, collections, workspaces]); + }; const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => { setImportCollectionModalOpen(false); @@ -176,7 +178,7 @@ const CollectionsSection = () => { }; const selectAllCollectionsToClose = () => { - setCollectionsToClose(workspaceCollections.map((c) => c.uid)); + setCollectionsToClose(getWorkspaceCollections().map((c) => c.uid)); }; const clearCollectionsToClose = () => { @@ -194,7 +196,7 @@ const CollectionsSection = () => { return; } - const scratchCollection = collections.find((c) => c.uid === scratchCollectionUid); + const scratchCollection = selectCollections(store.getState()).find((c) => c.uid === scratchCollectionUid); if (!scratchCollection) { toast.error('Unable to create request'); return; diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js index 7a44ef1dbad..bda6db279ab 100644 --- a/packages/bruno-app/src/components/StatusBar/index.js +++ b/packages/bruno-app/src/components/StatusBar/index.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import find from 'lodash/find'; +import { selectActiveTab } from 'src/selectors/tab'; import { IconSettings, IconCookie, IconTool, IconSearch, IconPalette, IconBrandGithub } from '@tabler/icons'; import Mousetrap from 'mousetrap'; import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings'; @@ -18,12 +18,7 @@ const StatusBar = () => { const dispatch = useDispatch(); const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); const workspaces = useSelector((state) => state.workspaces.workspaces); - const showHomePage = useSelector((state) => state.app.showHomePage); - const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage); - const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage); - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = useSelector(selectActiveTab); const logs = useSelector((state) => state.logs.logs); const [cookiesOpen, setCookiesOpen] = useState(false); const { version } = useApp(); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js index 64200e52ee4..8713d2fa18a 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js @@ -1,5 +1,6 @@ import React, { useState, useMemo, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { selectCollections } from 'src/selectors/collections'; import { IconBox, IconTrash, @@ -30,7 +31,7 @@ import StyledWrapper from './StyledWrapper'; const CollectionsList = ({ workspace }) => { const dispatch = useDispatch(); - const { collections } = useSelector((state) => state.collections); + const collections = useSelector(selectCollections); const dropdownRefs = useRef({}); const [renameCollectionModalOpen, setRenameCollectionModalOpen] = useState(false); diff --git a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js index 030b23ec382..daf6cdedb5c 100644 --- a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js +++ b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js @@ -1,4 +1,3 @@ -import find from 'lodash/find'; import { updateRequestPaneTabHeight, updateRequestPaneTabWidth, @@ -8,16 +7,16 @@ import { expandResponsePane } from 'providers/ReduxStore/slices/tabs'; import { useDispatch, useSelector } from 'react-redux'; +import { selectTabByUid } from 'src/selectors/tab'; const MIN_TOP_PANE_HEIGHT = 380; export function useTabPaneBoundaries(activeTabUid) { const DEFAULT_PANE_WIDTH_DIVISOR = 2.2; - const tabs = useSelector((state) => state.tabs.tabs); - const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + const focusedTab = useSelector((state) => selectTabByUid(state, activeTabUid)); const screenWidth = useSelector((state) => state.app.screenWidth); - let asideWidth = useSelector((state) => state.app.leftSidebarWidth); + const asideWidth = useSelector((state) => state.app.leftSidebarWidth); const isSidebarHidden = useSelector((state) => state.app.sidebarCollapsed); const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR; const top = focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT; @@ -56,7 +55,7 @@ export function useTabPaneBoundaries(activeTabUid) { dispatch(expandResponsePane({ uid: activeTabUid })); }, reset() { - let usableAsideWidth = isSidebarHidden ? 0 : asideWidth; + const usableAsideWidth = isSidebarHidden ? 0 : asideWidth; dispatch(expandRequestPane({ uid: activeTabUid })); dispatch(expandResponsePane({ uid: activeTabUid })); dispatch(updateRequestPaneTabHeight({ diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index 5183253c4cf..c5396b28744 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import find from 'lodash/find'; import Mousetrap from 'mousetrap'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector, useDispatch, useStore } from 'react-redux'; import NewRequest from 'components/Sidebar/NewRequest'; import GlobalSearchModal from 'components/GlobalSearchModal'; import SaveRequestsModal from 'providers/App/ConfirmAppClose/SaveRequestsModal'; @@ -16,14 +16,18 @@ import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; import { isEnvironmentValidationError } from 'utils/environments'; import toast from 'react-hot-toast'; import { getKeyBindingsForActionAllOS } from './keyMappings'; +import { selectCollections } from 'src/selectors/collections'; +import { selectTabs, selectActiveTabUid } from 'src/selectors/tab'; export const HotkeysContext = React.createContext(); export const HotkeysProvider = (props) => { const dispatch = useDispatch(); - const tabs = useSelector((state) => state.tabs.tabs); - const collections = useSelector((state) => state.collections.collections); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const store = useStore(); + const activeTabUid = useSelector(selectActiveTabUid); + + const getTabs = () => selectTabs(store.getState()); + const getCollections = () => selectCollections(store.getState()); const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings); const keybindingsEnabled = useSelector((state) => state.app.preferences?.keybindingsEnabled !== false); const [showNewRequestModal, setShowNewRequestModal] = useState(false); @@ -34,9 +38,9 @@ export const HotkeysProvider = (props) => { const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed); const getCurrentCollection = () => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = find(getTabs(), (t) => t.uid === activeTabUid); if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); + const collection = findCollectionByUid(getCollections(), activeTab.collectionUid); return collection; } @@ -44,9 +48,9 @@ export const HotkeysProvider = (props) => { // Get tabs scoped to the active tab's collection const getCollectionTabs = () => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = find(getTabs(), (t) => t.uid === activeTabUid); if (!activeTab) return []; - return tabs.filter((t) => t.collectionUid === activeTab.collectionUid); + return getTabs().filter((t) => t.collectionUid === activeTab.collectionUid); }; // Helper: get Mousetrap combos for an action, merged with user overrides @@ -69,9 +73,9 @@ export const HotkeysProvider = (props) => { // edit environments useEffect(() => { bindAction('editEnvironment', (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = find(getTabs(), (t) => t.uid === activeTabUid); if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); + const collection = findCollectionByUid(getCollections(), activeTab.collectionUid); if (collection) { dispatch( @@ -90,7 +94,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('editEnvironment'); }; - }, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // global search useEffect(() => { @@ -119,7 +123,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('switchToPreviousTab'); }; - }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // Switch to the next tab (active-collection-tabs-only) useEffect(() => { @@ -135,7 +139,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('switchToNextTab'); }; - }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // Switch to tab at position (Cmd+1 through Cmd+8) and last tab (Cmd+9) — collection-scoped useEffect(() => { @@ -165,17 +169,17 @@ export const HotkeysProvider = (props) => { } unbindAction('switchToLastTab'); }; - }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // Close all tabs useEffect(() => { bindAction('closeAllTabs', (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = find(getTabs(), (t) => t.uid === activeTabUid); if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); + const collection = findCollectionByUid(getCollections(), activeTab.collectionUid); if (collection) { - const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid); + const tabUids = getTabs().filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid); setTabUidsToClose(tabUids); setShowSaveRequestsModal(true); } @@ -187,12 +191,12 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('closeAllTabs'); }; - }, [activeTabUid, tabs, collections, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, userKeyBindings, keybindingsEnabled]); // Reopen last closed tab (active-collection-tabs-only) useEffect(() => { bindAction('reopenLastClosedTab', (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = find(getTabs(), (t) => t.uid === activeTabUid); if (activeTab?.collectionUid) { dispatch(reopenClosedTab({ collectionUid: activeTab.collectionUid })); } else { @@ -204,7 +208,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('reopenLastClosedTab'); }; - }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // Save all tabs (active-collection-tabs-only) useEffect(() => { @@ -262,7 +266,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('saveAllTabs'); }; - }, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // Collapse sidebar useEffect(() => { @@ -298,16 +302,16 @@ export const HotkeysProvider = (props) => { } // 2. No sidebar focus → check active tab type - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = find(getTabs(), (t) => t.uid === activeTabUid); if (activeTab) { if (activeTab.type === 'collection-settings' && activeTab.collectionUid) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); + const collection = findCollectionByUid(getCollections(), activeTab.collectionUid); if (collection?.pathname) { openDevtoolsAndSwitchToTerminal(dispatch, collection.pathname); return false; } } else if (activeTab.type === 'folder-settings' && activeTab.collectionUid && activeTab.uid) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); + const collection = findCollectionByUid(getCollections(), activeTab.collectionUid); if (collection) { const item = findItemInCollection(collection, activeTab.uid); if (item?.pathname) { @@ -328,7 +332,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('openTerminal'); }; - }, [focusedSidebarPath, activeTabUid, tabs, collections, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]); + }, [focusedSidebarPath, activeTabUid, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]); // Move tab left (active-collection-tabs-only) useEffect(() => { @@ -343,7 +347,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('moveTabLeft'); }; - }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // Move tab right (active-collection-tabs-only) useEffect(() => { @@ -358,12 +362,12 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('moveTabRight'); }; - }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]); // Open preferences useEffect(() => { bindAction('openPreferences', (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = find(getTabs(), (t) => t.uid === activeTabUid); const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid; dispatch( @@ -379,7 +383,7 @@ export const HotkeysProvider = (props) => { return () => { unbindAction('openPreferences'); }; - }, [activeTabUid, tabs, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]); + }, [activeTabUid, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]); // Change layout orientation useEffect(() => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index dd1f727810e..1ef8904d187 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -4,7 +4,6 @@ import { find, map, concat, filter, each, cloneDeep, get, set, pick, isEqual } f import { createSlice } from '@reduxjs/toolkit'; import { hexy as hexdump } from 'hexy'; import { - addDepth, areItemsTheSameExceptSeqUpdate, collapseAllItemsInCollection, deleteItemInCollection, @@ -270,7 +269,6 @@ export const collectionsSlice = createSlice({ collection.lastAction = null; collapseAllItemsInCollection(collection); - addDepth(collection.items); if (!collectionUids.includes(collection.uid)) { state.collections.push(collection); } @@ -493,7 +491,6 @@ export const collectionsSlice = createSlice({ item.items.push(action.payload.item); } } - addDepth(collection.items); } }, deleteItem: (state, action) => { @@ -751,7 +748,7 @@ export const collectionsSlice = createSlice({ // Get current response state or create initial state const currentResponse = item.response || initiatedGrpcResponse; const timestamp = item?.requestSent?.timestamp; - let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) }; + const updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) }; // Process based on event type switch (eventType) { @@ -1149,6 +1146,17 @@ export const collectionsSlice = createSlice({ } } }, + toggleRequestExamples: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + + if (collection) { + const item = findItemInCollection(collection, action.payload.itemUid); + + if (item && item.type === 'http-request') { + item.examplesExpanded = !item.examplesExpanded; + } + } + }, requestUrlChanged: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -2656,7 +2664,7 @@ export const collectionsSlice = createSlice({ folder.draft = cloneDeep(folder.root); } if (type === 'request') { - let vars = get(folder, 'draft.request.vars.req', []); + const vars = get(folder, 'draft.request.vars.req', []); const _var = find(vars, (h) => h.uid === action.payload.var.uid); if (_var) { _var.name = action.payload.var.name; @@ -2666,7 +2674,7 @@ export const collectionsSlice = createSlice({ } set(folder, 'draft.request.vars.req', vars); } else if (type === 'response') { - let vars = get(folder, 'draft.request.vars.res', []); + const vars = get(folder, 'draft.request.vars.res', []); const _var = find(vars, (h) => h.uid === action.payload.var.uid); if (_var) { _var.name = action.payload.var.name; @@ -2920,7 +2928,7 @@ export const collectionsSlice = createSlice({ }; } if (type === 'request') { - let vars = get(collection, 'draft.root.request.vars.req', []); + const vars = get(collection, 'draft.root.request.vars.req', []); const _var = find(vars, (h) => h.uid === action.payload.var.uid); if (_var) { _var.name = action.payload.var.name; @@ -2930,7 +2938,7 @@ export const collectionsSlice = createSlice({ } set(collection, 'draft.root.request.vars.req', vars); } else if (type === 'response') { - let vars = get(collection, 'draft.root.request.vars.res', []); + const vars = get(collection, 'draft.root.request.vars.res', []); const _var = find(vars, (h) => h.uid === action.payload.var.uid); if (_var) { _var.name = action.payload.var.name; @@ -3145,7 +3153,6 @@ export const collectionsSlice = createSlice({ }); } } - addDepth(collection.items); } }, collectionAddDirectoryEvent: (state, action) => { @@ -3197,7 +3204,6 @@ export const collectionsSlice = createSlice({ } currentSubItems = childItem.items; }); - addDepth(collection.items); } }, collectionChangeFileEvent: (state, action) => { @@ -3793,7 +3799,6 @@ export const collectionsSlice = createSlice({ }; annotateTransient(collection.items); } - addDepth(collection.items); }, collectionAddOauth2CredentialsByUrl: (state, action) => { const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo, executionMode } = action.payload; @@ -3804,7 +3809,7 @@ export const collectionsSlice = createSlice({ if (!collection.oauth2Credentials) { collection.oauth2Credentials = []; } - let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials); + const collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials); // Remove existing credentials for the same combination const filteredOauth2Credentials = filter( @@ -3861,7 +3866,7 @@ export const collectionsSlice = createSlice({ if (!collection) return; if (collection.oauth2Credentials) { - let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials); + const collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials); const filteredOauth2Credentials = filter( collectionOauth2Credentials, (creds) => @@ -4023,7 +4028,7 @@ export const collectionsSlice = createSlice({ // Get current response state or create initial state const currentResponse = item.response || initiatedWsResponse; const timestamp = item?.requestSent?.timestamp; - let updatedResponse = { + const updatedResponse = { ...currentResponse, isError: false, error: '', @@ -4268,6 +4273,7 @@ export const { expandItem, collapseItem, toggleCollectionItem, + toggleRequestExamples, requestUrlChanged, updateItemSettings, updateAuth, diff --git a/packages/bruno-app/src/selectors/collections.js b/packages/bruno-app/src/selectors/collections.js new file mode 100644 index 00000000000..2060bdd7ac5 --- /dev/null +++ b/packages/bruno-app/src/selectors/collections.js @@ -0,0 +1,82 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { + findCollectionByUid, + findItemInCollection, + getGlobalEnvironmentVariables, + getGlobalEnvironmentVariablesMasked +} from 'utils/collections/index'; + +/** + * Narrow selectors for the collections slice. + * + * `state.collections.collections` is replaced whenever a collection or request + * changes, so subscribing to the whole array causes re-renders for unrelated + * edits. Prefer selecting the smallest existing reference a component needs. + * + * Rules: + * - Select a single field or existing reference directly; no equality function + * is needed. + * - Use `createSelector` for derived values. Use a `make…` factory when each + * mounted component needs its own memoization cache. + * - If data is only needed at event time, don't subscribe to it; read it with + * `useStore().getState()` inside the event handler. + */ + +// The full collections array. Use only when rendering the collection list itself. +// Other components should select a specific collection, item, or field instead. +export const selectCollections = (state) => state.collections.collections; + +export const selectCollectionByUid = (state, collectionUid) => + collectionUid ? findCollectionByUid(state.collections.collections, collectionUid) : undefined; + +export const selectCollectionName = (state, collectionUid) => selectCollectionByUid(state, collectionUid)?.name; + +export const selectCollectionPathname = (state, collectionUid) => + selectCollectionByUid(state, collectionUid)?.pathname; + +export const selectCollectionMountStatus = (state, collectionUid) => + selectCollectionByUid(state, collectionUid)?.mountStatus; + +export const selectItemByUid = (state, collectionUid, itemUid) => { + const collection = selectCollectionByUid(state, collectionUid); + return collection && itemUid ? findItemInCollection(collection, itemUid) : undefined; +}; + +export const selectCollectionSortOrder = (state) => state.collections.collectionSortOrder; + +export const selectSelectedSidebarUids = (state) => state.collections.selectedSidebarUids; + +export const selectActiveWorkspace = (state) => { + const { workspaces, activeWorkspaceUid } = state.workspaces; + return workspaces?.find((w) => w.uid === activeWorkspaceUid); +}; + +const selectGlobalEnvironments = (state) => state.globalEnvironments.globalEnvironments; +const selectActiveGlobalEnvironmentUid = (state) => state.globalEnvironments.activeGlobalEnvironmentUid; + +/** + * The collection with the active global environment grafted on, the shape the + * request/response panes expect. Replaces the `produce()` call that used to + * run in RequestTabPanel's render body and handed the whole subtree a fresh + * collection object on every render. + * + * Per-instance factory: the merged object is recomputed only when the + * collection reference or the global environments change. + */ +export const makeSelectCollectionWithGlobals = () => + createSelector( + [selectCollectionByUid, selectGlobalEnvironments, selectActiveGlobalEnvironmentUid], + (collection, globalEnvironments, activeGlobalEnvironmentUid) => { + if (!collection) { + return collection; + } + + return { + ...collection, + globalEnvironmentVariables: getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }), + globalEnvSecrets: getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid }), + globalEnvironments, + activeGlobalEnvironmentUid + }; + } + ); diff --git a/packages/bruno-app/src/selectors/collections.spec.js b/packages/bruno-app/src/selectors/collections.spec.js new file mode 100644 index 00000000000..34b46502026 --- /dev/null +++ b/packages/bruno-app/src/selectors/collections.spec.js @@ -0,0 +1,103 @@ +import { buildTestState } from 'test-utils/buildTestState'; +import { + selectCollections, + selectCollectionByUid, + selectCollectionName, + selectItemByUid, + selectActiveWorkspace, + makeSelectCollectionWithGlobals +} from './collections'; + +const request = (uid, name) => ({ uid, name, type: 'http-request', request: { url: `/${name}` } }); + +const buildState = () => + buildTestState({ + collections: { + collections: [ + { uid: 'col-a', name: 'A', items: [{ uid: 'folder-1', type: 'folder', items: [request('req-1', 'one')] }] }, + { uid: 'col-b', name: 'B', items: [request('req-2', 'two')] } + ] + }, + globalEnvironments: { + globalEnvironments: [ + { uid: 'genv-1', name: 'Global', variables: [{ name: 'host', value: 'https://x', enabled: true, secret: false }] } + ], + activeGlobalEnvironmentUid: 'genv-1' + }, + workspaces: { + workspaces: [{ uid: 'ws-1', name: 'One' }, { uid: 'ws-2', name: 'Two' }], + activeWorkspaceUid: 'ws-2' + } + }); + +describe('selectors/collections', () => { + it('selectCollectionByUid returns the stored reference, or undefined', () => { + const state = buildState(); + expect(selectCollectionByUid(state, 'col-a')).toBe(state.collections.collections[0]); + expect(selectCollectionByUid(state, 'nope')).toBeUndefined(); + expect(selectCollectionByUid(state, undefined)).toBeUndefined(); + }); + + it('selectCollectionName reads one field', () => { + expect(selectCollectionName(buildState(), 'col-b')).toBe('B'); + expect(selectCollectionName(buildState(), 'missing')).toBeUndefined(); + }); + + it('selectItemByUid walks nested folders and returns the stored reference', () => { + const state = buildState(); + const item = selectItemByUid(state, 'col-a', 'req-1'); + expect(item).toBe(state.collections.collections[0].items[0].items[0]); + expect(selectItemByUid(state, 'col-a', 'req-2')).toBeUndefined(); + expect(selectItemByUid(state, 'col-zzz', 'req-1')).toBeUndefined(); + }); + + it('selectActiveWorkspace resolves the active uid', () => { + expect(selectActiveWorkspace(buildState())).toEqual({ uid: 'ws-2', name: 'Two' }); + }); + + it('selectCollections is the raw array reference', () => { + const state = buildState(); + expect(selectCollections(state)).toBe(state.collections.collections); + }); + + describe('makeSelectCollectionWithGlobals', () => { + it('grafts the active global environment onto the collection', () => { + const select = makeSelectCollectionWithGlobals(); + const merged = select(buildState(), 'col-a'); + expect(merged.uid).toBe('col-a'); + expect(merged.globalEnvironmentVariables).toEqual({ host: 'https://x' }); + expect(merged.activeGlobalEnvironmentUid).toBe('genv-1'); + expect(merged.globalEnvironments).toHaveLength(1); + }); + + it('returns the same object while its inputs are unchanged', () => { + const select = makeSelectCollectionWithGlobals(); + const state = buildState(); + expect(select(state, 'col-a')).toBe(select(state, 'col-a')); + }); + + it('recomputes when the collection reference changes and not when an unrelated slice does', () => { + const select = makeSelectCollectionWithGlobals(); + const state = buildState(); + const first = select(state, 'col-a'); + + const unrelated = { ...state, app: { ...state.app, isDragging: true } }; + expect(select(unrelated, 'col-a')).toBe(first); + + const edited = { + ...state, + collections: { + ...state.collections, + collections: state.collections.collections.map((c) => (c.uid === 'col-a' ? { ...c, name: 'A2' } : c)) + } + }; + const second = select(edited, 'col-a'); + expect(second).not.toBe(first); + expect(second.name).toBe('A2'); + }); + + it('returns undefined for a missing collection', () => { + expect(makeSelectCollectionWithGlobals()(buildState(), 'missing')).toBeUndefined(); + }); + }); +}); diff --git a/packages/bruno-app/src/selectors/tab.js b/packages/bruno-app/src/selectors/tab.js index 55ff783087d..e1bdd5c6d61 100644 --- a/packages/bruno-app/src/selectors/tab.js +++ b/packages/bruno-app/src/selectors/tab.js @@ -1,5 +1,36 @@ import { createSelector } from '@reduxjs/toolkit'; +/** + * Narrow selectors for the tabs slice. + * + * `state.tabs.tabs` changes on every tab action, so subscribing to the whole + * array causes re-renders for unrelated tab changes. Prefer selecting the + * specific tab or value the component needs. + */ + +// The full tab array. Use only when the component needs to render all tabs. +export const selectTabs = (state) => state.tabs.tabs; + +export const selectActiveTabUid = (state) => state.tabs.activeTabUid; + +export const selectTabByUid = (state, tabUid) => + tabUid ? state.tabs.tabs.find((t) => t.uid === tabUid) : undefined; + +export const selectActiveTab = (state) => + selectTabByUid(state, state.tabs.activeTabUid); + +/** + * Creates a memoized selector for tabs belonging to a collection. + * + * The selector is created per component instance so each consumer maintains + * its own cache. Create it with `useMemo` when used inside a component. + */ +export const makeSelectTabsForCollection = () => + createSelector( + [selectTabs, (_state, collectionUid) => collectionUid], + (tabs, collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid) + ); + export const getTabUidForItem = ({ itemUid, itemPathname, collectionUid }) => createSelector([ (state) => state.tabs.tabs ], (tabs) => { diff --git a/packages/bruno-app/src/selectors/tab.spec.js b/packages/bruno-app/src/selectors/tab.spec.js index cb642c55d6a..e00a7357b41 100644 --- a/packages/bruno-app/src/selectors/tab.spec.js +++ b/packages/bruno-app/src/selectors/tab.spec.js @@ -1,4 +1,14 @@ -import { getTabUidForItem, isTabForItemActive, isTabForItemPresent } from './tab'; +import { buildTestState } from 'test-utils/buildTestState'; +import { + getTabUidForItem, + isTabForItemActive, + isTabForItemPresent, + selectTabs, + selectActiveTabUid, + selectTabByUid, + selectActiveTab, + makeSelectTabsForCollection +} from './tab'; describe('tab selectors', () => { const baseState = { @@ -88,3 +98,64 @@ describe('tab selectors', () => { expect(selector(state)).toBe('request-1'); }); }); + +const tab = (uid, collectionUid, extra = {}) => ({ uid, collectionUid, type: 'request', ...extra }); + +const buildState = () => + buildTestState({ + tabs: { + tabs: [tab('t1', 'col-a'), tab('t2', 'col-b'), tab('t3', 'col-a')], + activeTabUid: 't2' + } + }); + +describe('tab field selectors', () => { + it('selectTabs and selectActiveTabUid are plain field reads', () => { + const state = buildState(); + expect(selectTabs(state)).toBe(state.tabs.tabs); + expect(selectActiveTabUid(state)).toBe('t2'); + }); + + it('selectTabByUid returns the stored reference, or undefined', () => { + const state = buildState(); + expect(selectTabByUid(state, 't3')).toBe(state.tabs.tabs[2]); + expect(selectTabByUid(state, 'missing')).toBeUndefined(); + expect(selectTabByUid(state, null)).toBeUndefined(); + }); + + it('selectActiveTab resolves activeTabUid', () => { + const state = buildState(); + expect(selectActiveTab(state)).toBe(state.tabs.tabs[1]); + expect(selectActiveTab(buildTestState())).toBeUndefined(); + }); + + describe('makeSelectTabsForCollection', () => { + it('filters by collection uid', () => { + const result = makeSelectTabsForCollection()(buildState(), 'col-a'); + expect(result.map((t) => t.uid)).toEqual(['t1', 't3']); + }); + + it('is memoized on the tabs reference', () => { + const selectTabsForCollection = makeSelectTabsForCollection(); + const state = buildState(); + const first = selectTabsForCollection(state, 'col-a'); + expect(selectTabsForCollection(state, 'col-a')).toBe(first); + + const unrelated = { ...state, app: { ...state.app, isDragging: true } }; + expect(selectTabsForCollection(unrelated, 'col-a')).toBe(first); + + const changed = { ...state, tabs: { ...state.tabs, tabs: [...state.tabs.tabs, tab('t4', 'col-a')] } }; + expect(selectTabsForCollection(changed, 'col-a')).not.toBe(first); + expect(selectTabsForCollection(changed, 'col-a')).toHaveLength(3); + }); + + it('gives each instance its own cache', () => { + const state = buildState(); + const a = makeSelectTabsForCollection(); + const b = makeSelectTabsForCollection(); + const firstA = a(state, 'col-a'); + b(state, 'col-b'); + expect(a(state, 'col-a')).toBe(firstA); + }); + }); +}); diff --git a/packages/bruno-app/src/test-utils/buildTestState.js b/packages/bruno-app/src/test-utils/buildTestState.js new file mode 100644 index 00000000000..626a0631a2b --- /dev/null +++ b/packages/bruno-app/src/test-utils/buildTestState.js @@ -0,0 +1,59 @@ +/** + * Builds a complete Redux state for selector unit tests, with every slice + * initialized to the shape selectors expect. Tests only need to override the + * fields relevant to them. + * + * const state = buildTestState({ + * tabs: { tabs: [tab], activeTabUid: tab.uid } + * }); + * + * Keep the defaults aligned with each slice's `initialState` so selector tests + * continue to reflect the real store shape as slices evolve. + */ +const defaults = () => ({ + collections: { + collections: [], + collectionSortOrder: 'default', + activeConnections: [], + selectedSidebarUids: [], + lastClickedSidebarUid: null, + tempDirectories: {}, + saveTransientRequestModals: [], + mockResponseEditors: {} + }, + tabs: { + tabs: [], + activeTabUid: null, + recentlyClosedTabs: [] + }, + workspaces: { + workspaces: [], + activeWorkspaceUid: null + }, + globalEnvironments: { + globalEnvironments: [], + activeGlobalEnvironmentUid: null, + globalEnvironmentDraft: null, + _scriptGlobalEnvBaseline: null + }, + app: { + isDragging: false, + clipboard: { hasCopiedItems: false }, + preferences: {}, + leftSidebarWidth: 222, + sidebarCollapsed: false, + screenWidth: 1280 + }, + logs: { isConsoleOpen: false }, + chat: { isOpen: false, isPoppedOut: false } +}); + +export const buildTestState = (overrides = {}) => { + const state = defaults(); + for (const [slice, value] of Object.entries(overrides)) { + state[slice] = { ...(state[slice] || {}), ...value }; + } + return state; +}; + +export default buildTestState; diff --git a/packages/bruno-app/src/utils/collections/collectionSlug.js b/packages/bruno-app/src/utils/collections/collectionSlug.js new file mode 100644 index 00000000000..4362a6a7c8e --- /dev/null +++ b/packages/bruno-app/src/utils/collections/collectionSlug.js @@ -0,0 +1,7 @@ +/** + * @param {string} name - collection display name + * @returns {string} + */ +export const collectionSlug = (name) => (name || '').replace(/\s+/g, '-').toLowerCase(); + +export default collectionSlug; diff --git a/packages/bruno-app/src/utils/collections/flattenSidebarTree.js b/packages/bruno-app/src/utils/collections/flattenSidebarTree.js new file mode 100644 index 00000000000..dc5bb8a7666 --- /dev/null +++ b/packages/bruno-app/src/utils/collections/flattenSidebarTree.js @@ -0,0 +1,342 @@ +import { isItemAFolder, isItemARequest } from './index'; +import { collectionSlug } from './collectionSlug'; +import { sortByNameThenSequence } from 'utils/common/index'; +import { + doesRequestMatchSearchText, + doesFolderHaveItemsMatchSearchText, + doesCollectionHaveItemsMatchingSearchText +} from './search'; + +const groupCollectionItems = (collectionItems) => { + const folders = []; + const apps = []; + const requests = []; + + const sortBySeq = (items) => [...items].sort((a, b) => a.seq - b.seq); + + for (const item of collectionItems) { + if (!item || item.isTransient) continue; + + if (isItemAFolder(item)) { + folders.push(item); + } else if (item.type === 'app') { + apps.push(item); + } else if (isItemARequest(item)) { + requests.push(item); + } + } + + return { + folders: sortByNameThenSequence(folders), + apps: sortBySeq(apps), + requests: sortBySeq(requests) + }; +}; + +/** + * Flattens the children of a collection or folder into sidebar rows. + * Returns the number of visible children to determine whether an empty-state + * CTA should be shown. + */ +const walkChildren = ( + collectionContext, + { collectionItems = [], depth, parentName } +) => { + const { + collectionUid, + collectionPathname, + collectionId, + hasSearch, + searchText, + appendRow, + addItemToIndex + } = collectionContext; + + let visibleChildCount = 0; + + const { folders, apps, requests } = groupCollectionItems(collectionItems); + + for (const folder of folders) { + if (hasSearch && !doesFolderHaveItemsMatchSearchText(folder, searchText)) { + continue; + } + + visibleChildCount++; + + appendRow({ + id: `${collectionUid}:${folder.uid}`, + kind: 'folder', + depth, + collectionUid, + collectionPathname, + collectionId, + parentName, + itemUid: folder.uid, + sortName: folder.name || null + }); + + addItemToIndex(folder.uid, folder); + + // Search reveals matching descendants regardless of the collapsed state. + const isExpanded = hasSearch || !folder.collapsed; + + if (!isExpanded) continue; + + const childCount = walkChildren(collectionContext, { + collectionItems: folder.items, + depth: depth + 1, + parentName: folder.name || null + }); + + if (!hasSearch && childCount === 0) { + appendRow({ + id: `${collectionUid}:${folder.uid}:cta`, + kind: 'empty-cta', + depth: depth + 1, + collectionUid, + collectionPathname, + collectionId, + parentName: folder.name || null, + itemUid: folder.uid, + sortName: null + }); + } + } + + if (!hasSearch) { + for (const app of apps) { + visibleChildCount++; + + appendRow({ + id: `${collectionUid}:${app.uid}`, + kind: 'app', + depth, + collectionUid, + collectionPathname, + collectionId, + parentName, + itemUid: app.uid, + sortName: app.name || null + }); + + addItemToIndex(app.uid, app); + } + } + + for (const request of requests) { + if (hasSearch && !doesRequestMatchSearchText(request, searchText)) { + continue; + } + + visibleChildCount++; + + appendRow({ + id: `${collectionUid}:${request.uid}`, + kind: 'request', + depth, + collectionUid, + collectionPathname, + collectionId, + parentName, + itemUid: request.uid, + sortName: request.name || null + }); + + addItemToIndex(request.uid, request); + + const hasExamples + = request.type === 'http-request' && Array.isArray(request.examples); + + if (!hasSearch && hasExamples && request.examplesExpanded) { + request.examples.forEach((example, index) => { + appendRow({ + id: `${collectionUid}:${request.uid}:ex:${example.uid || index}`, + kind: 'example', + depth: depth + 1, + collectionUid, + collectionPathname, + collectionId, + parentName: request.name || null, + itemUid: request.uid, + sortName: example.name || null, + exampleIndex: index, + exampleUid: example.uid || null + }); + }); + } + } + + return visibleChildCount; +}; + +/** + * Adds a collection and its visible children to the flat sidebar row list. + */ +const flattenCollection = ({ + collection, + hasSearch, + searchText, + appendRow, + addItemToIndex, + addCollectionToIndex +}) => { + if ( + hasSearch + && !doesCollectionHaveItemsMatchingSearchText(collection, searchText) + ) { + return; + } + + // Used for readable test selectors. collectionUid remains the unique identity. + const collectionId = collectionSlug(collection.name); + + appendRow({ + id: `col:${collection.uid}`, + kind: 'collection', + depth: 0, + collectionUid: collection.uid, + collectionPathname: collection.pathname || null, + collectionId, + parentName: null, + itemUid: null, + sortName: collection.name || null + }); + + addCollectionToIndex(collection.uid, collection); + + // Search reveals matching descendants regardless of the collapsed state. + const isExpanded = hasSearch || !collection.collapsed; + + if (!isExpanded) return; + + const collectionContext = { + collectionUid: collection.uid, + collectionPathname: collection.pathname || null, + collectionId, + hasSearch, + searchText, + appendRow, + addItemToIndex + }; + + const visibleChildCount = walkChildren(collectionContext, { + collectionItems: collection.items, + depth: 1, + parentName: null + }); + + // append emtry row cta. + if ( + !hasSearch + && visibleChildCount === 0 + && collection.mountStatus === 'mounted' + && !collection.isLoading + ) { + appendRow({ + id: `${collection.uid}:root:cta`, + kind: 'empty-cta', + depth: 1, + collectionUid: collection.uid, + collectionPathname: collection.pathname || null, + collectionId, + parentName: null, + itemUid: null, + sortName: null + }); + } +}; + +/** + * convert sidebar entries into a flat, ordered array of layout rows. + * each row carry only structural data. + * + * @param {Array} sidebarEntries + * @param {{ searchText?: string }} options + * @returns {{ + * rows: Array, + * itemsByUid: Map, + * collectionsByUid: Map + * }} + */ +export const flattenSidebarTree = (sidebarEntries = [], options = {}) => { + const { searchText = '' } = options; + const hasSearch = Boolean(searchText.trim()); + + const rows = []; + const itemsByUid = new Map(); + const collectionsByUid = new Map(); + + const appendRow = (row) => rows.push(row); + const addItemToIndex = (uid, item) => itemsByUid.set(uid, item); + const addCollectionToIndex = (uid, collection) => + collectionsByUid.set(uid, collection); + + for (const entry of sidebarEntries) { + if (!entry) continue; + + // A ghost represents a missing Git-backed collection and is never expanded. + if (entry.kind === 'ghost') { + const ghost = entry.entry || {}; + + appendRow({ + id: `ghost:${ghost.path}`, + kind: 'ghost', + depth: 0, + collectionUid: null, + collectionPathname: ghost.path || null, + itemUid: null, + sortName: ghost.name || null + }); + + continue; + } + + if (!entry.collection) continue; + + flattenCollection({ + collection: entry.collection, + hasSearch, + searchText, + appendRow, + addItemToIndex, + addCollectionToIndex + }); + } + + return { + rows, + itemsByUid, + collectionsByUid + }; +}; + +/** + * lookups from item/collection UIDs to their row positions. + * needed for active tab to scroll into view in sidebar + */ +export const buildIndexes = (rows = []) => { + const rowIndexByItemUid = new Map(); + const rowIndexByCollectionUid = new Map(); + + rows.forEach((row, index) => { + if (row.kind === 'collection' && row.collectionUid) { + rowIndexByCollectionUid.set(row.collectionUid, index); + } + + if ( + ['folder', 'app', 'request'].includes(row.kind) + && row.itemUid + ) { + rowIndexByItemUid.set(row.itemUid, index); + } + + if (row.kind === 'example' && row.exampleUid) { + rowIndexByItemUid.set(row.exampleUid, index); + } + }); + + return { + rowIndexByItemUid, + rowIndexByCollectionUid + }; +}; diff --git a/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js b/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js new file mode 100644 index 00000000000..447a5c53bc2 --- /dev/null +++ b/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js @@ -0,0 +1,179 @@ +import { flattenSidebarTree, buildIndexes } from './flattenSidebarTree'; + +let uid = 0; +const nextUid = (p) => `${p}-${++uid}`; +const request = (name, props = {}) => ({ uid: props.uid || nextUid('req'), name, type: 'http-request', seq: props.seq, request: {}, ...props }); +const folder = (name, items = [], props = {}) => ({ uid: props.uid || nextUid('fol'), name, type: 'folder', seq: props.seq, items, ...props }); +const app = (name, props = {}) => ({ uid: props.uid || nextUid('app'), name, type: 'app', seq: props.seq, ...props }); +const collection = (name, items = [], props = {}) => ({ uid: props.uid || nextUid('col'), name, pathname: `/c/${name}`, mountStatus: 'mounted', isLoading: false, collapsed: false, items, ...props }); +const loaded = (c) => ({ kind: 'loaded', collection: c }); +const flatten = (entries, options) => flattenSidebarTree(entries, options).rows; +const kinds = (rows) => rows.map((r) => r.kind); +const names = (rows) => rows.map((r) => r.sortName); + +beforeEach(() => { uid = 0; }); + +describe('flattenSidebarTree', () => { + describe('ordering and depth', () => { + it('emits collection header then folders -> apps -> requests', () => { + const c = collection('C', [request('r1', { seq: 1 }), app('a1', { seq: 1 }), folder('f1', [], { seq: 1, collapsed: true })]); + expect(kinds(flatten([loaded(c)]))).toEqual(['collection', 'folder', 'app', 'request']); + }); + it('sorts requests/apps by seq, folders alphabetically', () => { + const c = collection('C', [request('rB', { seq: 2 }), request('rA', { seq: 1 }), folder('zeta'), folder('alpha')]); + const rows = flatten([loaded(c)]); + expect(names(rows.filter((r) => r.kind === 'request'))).toEqual(['rA', 'rB']); + expect(names(rows.filter((r) => r.kind === 'folder'))).toEqual(['alpha', 'zeta']); + }); + it('stamps depth: header 0, top-level 1, nested 2', () => { + const byName = Object.fromEntries(flatten([loaded(collection('C', [folder('f1', [request('r1')])]))]).map((r) => [r.sortName, r.depth])); + expect(byName.C).toBe(0); expect(byName.f1).toBe(1); expect(byName.r1).toBe(2); + }); + }); + + it('drops transient items', () => { + const c = collection('C', [request('real', { seq: 1 }), request('draft', { seq: 2, isTransient: true })]); + expect(names(flatten([loaded(c)]).filter((r) => r.kind === 'request'))).toEqual(['real']); + }); + + describe('collapse', () => { + it('collapsed collection = header only', () => { + expect(kinds(flatten([loaded(collection('C', [request('r1')], { collapsed: true }))]))).toEqual(['collection']); + }); + it('collapsed folder = row without subtree', () => { + const rows = flatten([loaded(collection('C', [folder('f1', [request('hidden')], { collapsed: true })]))]); + expect(kinds(rows)).toEqual(['collection', 'folder']); + expect(names(rows)).not.toContain('hidden'); + }); + }); + + describe('search', () => { + it('includes only matching requests, force-expanded', () => { + const c = collection('C', [folder('f1', [request('login'), request('logout')], { collapsed: true }), request('health')]); + const r = names(flatten([loaded(c)], { searchText: 'log' }).filter((x) => x.kind === 'request')); + expect(r).toEqual(expect.arrayContaining(['login', 'logout'])); + expect(r).not.toContain('health'); + }); + it('drops a collection with no matching request', () => { + expect(flatten([loaded(collection('C', [request('health')]))], { searchText: 'zzz' })).toHaveLength(0); + }); + it('includes a folder only if it has a matching descendant', () => { + const c = collection('C', [folder('match', [request('login')]), folder('nomatch', [request('health')])]); + expect(names(flatten([loaded(c)], { searchText: 'login' }).filter((x) => x.kind === 'folder'))).toEqual(['match']); + }); + it('hides apps and empty-cta while searching', () => { + const k = kinds(flatten([loaded(collection('C', [app('a'), request('login')]))], { searchText: 'login' })); + expect(k).not.toContain('app'); + expect(k).not.toContain('empty-cta'); + }); + }); + + describe('empty-cta', () => { + it('collection cta when mounted, empty, expanded', () => { + const rows = flatten([loaded(collection('C', []))]); + expect(kinds(rows)).toEqual(['collection', 'empty-cta']); + expect(rows[1].depth).toBe(1); + expect(rows[1].itemUid).toBeNull(); + }); + it('suppressed while loading or unmounted', () => { + expect(kinds(flatten([loaded(collection('C', [], { isLoading: true }))]))).toEqual(['collection']); + expect(kinds(flatten([loaded(collection('D', [], { mountStatus: 'unmounted' }))]))).toEqual(['collection']); + }); + it('folder cta at depth+1 for an empty expanded folder', () => { + const rows = flatten([loaded(collection('C', [folder('empty', [])]))]); + const cta = rows.find((r) => r.kind === 'empty-cta'); + expect(cta.depth).toBe(2); + expect(cta.itemUid).toBe(rows.find((r) => r.kind === 'folder').itemUid); + }); + }); + + it('emits a ghost row', () => { + const rows = flatten([{ kind: 'ghost', entry: { path: '/repo/x', name: 'X' } }]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ kind: 'ghost', collectionPathname: '/repo/x', sortName: 'X', depth: 0 }); + }); + + describe('examples', () => { + it('emits example rows when expanded at request depth + 1', () => { + const c = collection('C', [request('r1', { examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }, { uid: 'ex2', name: 'err' }] })]); + const rows = flatten([loaded(c)]); + const ex = rows.filter((r) => r.kind === 'example'); + expect(names(ex)).toEqual(['ok', 'err']); + const reqDepth = rows.find((r) => r.kind === 'request').depth; + expect(ex.every((r) => r.depth === reqDepth + 1)).toBe(true); + }); + it('omits example rows when not expanded', () => { + const c = collection('C', [request('r1', { examples: [{ uid: 'ex1', name: 'ok' }] })]); + expect(kinds(flatten([loaded(c)]))).not.toContain('example'); + }); + }); +}); + +describe('ancestry attributes', () => { + it('stamps collectionId (slug) on every row of the collection', () => { + const rows = flatten([loaded(collection('My Coll', [folder('f1', [request('r1')])]))]); + expect(rows.every((r) => r.collectionId === 'my-coll')).toBe(true); + }); + it('stamps parentName: folder name for a folder child, null at collection root', () => { + const rows = flatten([loaded(collection('C', [request('top'), folder('f1', [request('nested')])]))]); + expect(rows.find((r) => r.sortName === 'top').parentName).toBeNull(); + expect(rows.find((r) => r.sortName === 'f1').parentName).toBeNull(); + expect(rows.find((r) => r.sortName === 'nested').parentName).toBe('f1'); + }); + it('stamps collectionId + parentName (request name) on example rows', () => { + const c = collection('My Coll', [request('r1', { examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }] })]); + const ex = flatten([loaded(c)]).find((r) => r.kind === 'example'); + expect(ex.collectionId).toBe('my-coll'); + expect(ex.parentName).toBe('r1'); + }); + it('stamps collectionId + parentName on empty-cta rows', () => { + const rootCta = flatten([loaded(collection('Empty', []))]).find((r) => r.kind === 'empty-cta'); + expect(rootCta.collectionId).toBe('empty'); + expect(rootCta.parentName).toBeNull(); + const folderCta = flatten([loaded(collection('C', [folder('f1', [])]))]).find((r) => r.kind === 'empty-cta'); + expect(folderCta.parentName).toBe('f1'); + }); +}); + +describe('object maps', () => { + it('itemsByUid resolves folders, apps and requests to their live objects', () => { + const r = request('r1', { uid: 'req-x' }); + const a = app('a1', { uid: 'app-x' }); + const f = folder('f1', [r], { uid: 'fol-x' }); + const { itemsByUid } = flattenSidebarTree([loaded(collection('C', [f, a]))]); + expect(itemsByUid.get('fol-x')).toBe(f); + expect(itemsByUid.get('app-x')).toBe(a); + expect(itemsByUid.get('req-x')).toBe(r); + }); + it('collectionsByUid resolves the collection to its live object', () => { + const c = collection('C', [], { uid: 'col-x' }); + const { collectionsByUid } = flattenSidebarTree([loaded(c)]); + expect(collectionsByUid.get('col-x')).toBe(c); + }); + it('does not index items hidden by a collapsed parent (not walked)', () => { + const { itemsByUid } = flattenSidebarTree([loaded(collection('C', [folder('f1', [request('hidden', { uid: 'req-h' })], { collapsed: true })]))]); + expect(itemsByUid.has('req-h')).toBe(false); + }); +}); + +describe('buildIndexes', () => { + it('maps item uid and collection uid to row index', () => { + const c = collection('C', [request('r1', { uid: 'req-x' })], { uid: 'col-x' }); + const rows = flatten([loaded(c)]); + const { rowIndexByItemUid, rowIndexByCollectionUid } = buildIndexes(rows); + expect(rows[rowIndexByItemUid.get('req-x')].kind).toBe('request'); + expect(rowIndexByCollectionUid.get('col-x')).toBe(0); + }); + it('item-uid map targets the item row, not its example rows', () => { + const c = collection('C', [request('r1', { uid: 'req-x', examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }] })]); + const rows = flatten([loaded(c)]); + const { rowIndexByItemUid } = buildIndexes(rows); + expect(rows[rowIndexByItemUid.get('req-x')].kind).toBe('request'); + }); + it('indexes example rows by exampleUid', () => { + const c = collection('C', [request('r1', { uid: 'req-x', examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }] })]); + const rows = flatten([loaded(c)]); + const { rowIndexByItemUid } = buildIndexes(rows); + expect(rows[rowIndexByItemUid.get('ex1')].kind).toBe('example'); + }); +}); diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index efb3eba2310..d4bbf208ad5 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -20,20 +20,6 @@ const replaceTabsWithSpaces = (str, numSpaces = 2) => { return str.replaceAll('\t', ' '.repeat(numSpaces)); }; -export const addDepth = (items = []) => { - const depth = (itms, initialDepth) => { - each(itms, (i) => { - i.depth = initialDepth; - - if (i.items && i.items.length) { - depth(i.items, initialDepth + 1); - } - }); - }; - - depth(items, 1); -}; - const setCollapsedRecursively = (items, collapsed) => { each(items, (i) => { i.collapsed = collapsed; @@ -1436,6 +1422,8 @@ export const maskInputValue = (value) => { }; export const getTreePathFromCollectionToItem = (collection, _item) => { + if (!_item?.uid) return []; + let path = []; let item = findItemInCollection(collection, _item?.uid); while (item) { diff --git a/packages/bruno-app/src/utils/collections/search.spec.js b/packages/bruno-app/src/utils/collections/search.spec.js new file mode 100644 index 00000000000..04008395e64 --- /dev/null +++ b/packages/bruno-app/src/utils/collections/search.spec.js @@ -0,0 +1,61 @@ +import { + doesRequestMatchSearchText, + doesFolderHaveItemsMatchSearchText, + doesCollectionHaveItemsMatchingSearchText +} from './search'; + +const createRequest = (name, props = {}) => ({ + uid: name, + name, + type: 'http-request', + request: {}, + ...props +}); + +const createFolder = (name, items = []) => ({ + uid: name, + name, + type: 'folder', + items +}); + +describe('whether a request matches the search text', () => { + it('matches request names case-insensitively', () => { + expect(doesRequestMatchSearchText(createRequest('GetUser'), 'user')).toBe(true); + expect(doesRequestMatchSearchText(createRequest('GetUser'), 'xyz')).toBe(false); + }); +}); + +describe('whether a folder contains a matching request', () => { + it('matches requests nested inside folders', () => { + const folder = createFolder('root', [ + createFolder('subfolder', [createRequest('login')]), + createRequest('health') + ]); + + expect(doesFolderHaveItemsMatchSearchText(folder, 'login')).toBeTruthy(); + expect(doesFolderHaveItemsMatchSearchText(folder, 'zzz')).toBeFalsy(); + }); + + it('ignores transient requests', () => { + const folder = createFolder('root', [ + createRequest('login', { isTransient: true }) + ]); + + expect(doesFolderHaveItemsMatchSearchText(folder, 'login')).toBeFalsy(); + }); +}); + +describe('whether a collection contains a matching request', () => { + it('matches requests anywhere in the collection tree', () => { + const collection = { + items: [ + createFolder('folder', [createRequest('deep-login')]), + createRequest('health') + ] + }; + + expect(doesCollectionHaveItemsMatchingSearchText(collection, 'login')).toBeTruthy(); + expect(doesCollectionHaveItemsMatchingSearchText(collection, 'zzz')).toBeFalsy(); + }); +}); diff --git a/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts b/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts index dcb2d7f7d7d..897fa025aec 100644 --- a/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts +++ b/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts @@ -20,10 +20,7 @@ test.describe('Cross-Format Collection Drag and Drop', () => { // Expand the bru collection and locate the request await page.locator('#sidebar-collection-name').filter({ hasText: 'bru-collection' }).click(); - const bruCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'bru-collection' }) - .locator('..'); + const bruCollectionContainer = page.locator('[data-collection-id="bru-collection"]'); const bruRequest = bruCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await expect(bruRequest).toBeVisible(); @@ -32,10 +29,7 @@ test.describe('Cross-Format Collection Drag and Drop', () => { await bruRequest.dragTo(ymlCollection); // Verify the request appears in the yml collection (increase timeout for file watcher processing) - const ymlCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'yml-collection' }) - .locator('..'); + const ymlCollectionContainer = page.locator('[data-collection-id="yml-collection"]'); // The yml collection may need to be expanded after the drop const ymlCollectionItems = ymlCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }); // Wait for file watcher to process the new file, then expand collection if needed diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts index 64104758e80..bd393233354 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts @@ -43,19 +43,13 @@ test.describe('Cross-Collection Drag and Drop for folder', () => { await sourceFolder.dragTo(targetCollection); // Verify the folder has been moved to the target collection - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect( targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-folder' }) ).toBeVisible(); // Verify the folder (and its request) is no longer in the source collection. - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); await expect( sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-folder' }) ).not.toBeVisible(); @@ -98,20 +92,14 @@ test.describe('Cross-Collection Drag and Drop for folder', () => { await expect(page.getByText(/already exists/i)).toHaveCount(0); // The folder is moved out of the source collection. - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); await expect( sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'folder-1' }) ).toHaveCount(0); // The target now shows two "folder-1" entries (the original and the moved one; // the directory name was silently suffixed on disk). - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect( targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'folder-1' }) ).toHaveCount(2); diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts index 4929c8f7299..165b8dad790 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts @@ -23,10 +23,7 @@ test.describe('Cross-Collection Drag and Drop', () => { await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible(); // Locate the request in source collection - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); const sourceRequest = sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await expect(sourceRequest).toBeVisible(); @@ -39,10 +36,7 @@ test.describe('Cross-Collection Drag and Drop', () => { // Verify the request has been moved to the target collection // Check that the request now appears under target collection - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toBeVisible(); // Verify the request is no longer in the source collection @@ -71,14 +65,8 @@ test.describe('Cross-Collection Drag and Drop', () => { // Go back to source collection to drag the request await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click(); - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); const sourceRequest = sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await expect(sourceRequest).toBeVisible(); @@ -110,10 +98,7 @@ test.describe('Cross-Collection Drag and Drop', () => { await createCollection(page, 'target-collection', await createTmpDir('target-collection')); // Open the request to create a tab - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); const sourceRequest = sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await sourceRequest.click(); @@ -129,10 +114,7 @@ test.describe('Cross-Collection Drag and Drop', () => { await expect(requestTab).not.toBeVisible(); // Verify the request appears in the target collection - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toBeVisible(); }); }); diff --git a/tests/environments/import-environment/global-env-import.spec.ts b/tests/environments/import-environment/global-env-import.spec.ts index 49a252fc1a1..878a83b2b1f 100644 --- a/tests/environments/import-environment/global-env-import.spec.ts +++ b/tests/environments/import-environment/global-env-import.spec.ts @@ -70,7 +70,7 @@ test.describe('Global Environment Import Tests', () => { await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); - await page.locator('#collection-environment-test-collection .collection-item-name').first().click(); + await page.locator('[data-collection-id="environment-test-collection"] .collection-item-name').first().click(); await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}'); await page.locator('[data-testid="send-arrow-icon"]').click(); await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' }); @@ -81,7 +81,7 @@ test.describe('Global Environment Import Tests', () => { await expect(responsePane).toContainText('"userId": 1'); // Test POST request - await page.locator('#collection-environment-test-collection .collection-item-name').nth(1).click(); + await page.locator('[data-collection-id="environment-test-collection"] .collection-item-name').nth(1).click(); await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts'); await page.locator('[data-testid="send-arrow-icon"]').click(); await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' }); diff --git a/tests/import/openapi/duplicate-operation-names-fix.spec.ts b/tests/import/openapi/duplicate-operation-names-fix.spec.ts index 655e170d30f..6b46e80cc57 100644 --- a/tests/import/openapi/duplicate-operation-names-fix.spec.ts +++ b/tests/import/openapi/duplicate-operation-names-fix.spec.ts @@ -40,6 +40,6 @@ test.describe('OpenAPI Duplicate Names Handling', () => { await page.locator('#sidebar-collection-name').getByText('Duplicate Test Collection').click(); // verify that all 3 requests were imported correctly despite duplicate operation names - await expect(page.locator('#collection-duplicate-test-collection .collection-item-name')).toHaveCount(3); + await expect(page.locator('[data-collection-id="duplicate-test-collection"] .collection-item-name')).toHaveCount(3); }); }); diff --git a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts index c086ebecdb9..c995af99ef9 100644 --- a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts +++ b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts @@ -40,6 +40,6 @@ test.describe('OpenAPI Newline Handling', () => { // verify that all requests were imported correctly despite newlines in operation names // the parser should clean up the operation names and create valid request names - await expect(page.locator('#collection-newline-test-collection .collection-item-name')).toHaveCount(2); + await expect(page.locator('[data-collection-id="newline-test-collection"] .collection-item-name')).toHaveCount(2); }); }); diff --git a/tests/import/wsdl/import-wsdl.spec.ts b/tests/import/wsdl/import-wsdl.spec.ts index a139004297d..faec11ebe94 100644 --- a/tests/import/wsdl/import-wsdl.spec.ts +++ b/tests/import/wsdl/import-wsdl.spec.ts @@ -49,20 +49,20 @@ test.describe('Import WSDL Collection', () => { await openCollection(page, 'TestWSDLServiceXML'); // verify that all requests were imported correctly - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name')).toHaveCount(1); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name')).toHaveCount(1); }); await test.step('Verify that folders and requests were imported correctly', async () => { - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('UserService')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('UserService')).toBeVisible(); // open the user service folder - await page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('UserService').click(); + await page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('UserService').click(); - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('GetUser')).toBeVisible(); - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('CreateUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('GetUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('CreateUser')).toBeVisible(); }); await test.step('Verify the GetUser request is imported correctly', async () => { - await page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('GetUser').click(); + await page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('GetUser').click(); await expect(page.locator('.request-tab.active').getByText('GetUser')).toBeVisible(); await expect(page.locator('#request-url').getByText('http://example.com/soap/userservice')).toBeVisible(); }); @@ -110,20 +110,20 @@ test.describe('Import WSDL Collection', () => { await openCollection(page, 'TestWSDLServiceJSON'); // verify that all requests were imported correctly - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name')).toHaveCount(1); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name')).toHaveCount(1); }); await test.step('Verify that folders and requests were imported correctly', async () => { - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('UserService')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('UserService')).toBeVisible(); // open the user service folder - await page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('UserService').click(); + await page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('UserService').click(); - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('GetUser')).toBeVisible(); - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('CreateUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('GetUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('CreateUser')).toBeVisible(); }); await test.step('Verify the CreateUser request is imported correctly', async () => { - await page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('CreateUser').click(); + await page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('CreateUser').click(); await expect(page.locator('.request-tab.active').getByText('CreateUser')).toBeVisible(); await expect(page.locator('#request-url').getByText('http://example.com/soap/userservice')).toBeVisible(); }); diff --git a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts index c6edeab549e..eb0a1081c12 100644 --- a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts +++ b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, Page } from '../../../playwright'; +import { test, expect } from '../../../playwright'; import { buildCommonLocators, closeAllCollections } from '../../utils/page'; test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { @@ -12,11 +12,6 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { await closeAllCollections(page); }); - // Scope an assertion to a single collection — pageWithUserData reuses one app - // across the describe block, and multiple expanded collections would otherwise - // make `getByTestId('add-request-cta')` match more than one element. - const collectionScope = (page: Page, name: string) => page.locator(`#collection-${name}`); - const expandCollection = async (name: string) => { const collection = locators.sidebar.collection(name); await collection.waitFor({ state: 'visible' }); @@ -31,7 +26,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'empty-bru').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('empty-bru').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -41,7 +36,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'empty-yml').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('empty-yml').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -53,7 +48,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'bru-with-js').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('bru-with-js').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -63,7 +58,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'yml-with-js').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('yml-with-js').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -76,7 +71,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'bru-with-request').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('bru-with-request').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -87,7 +82,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'yml-with-request').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('yml-with-request').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -98,7 +93,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'bru-folder-with-js').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('bru-folder-with-js').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -109,7 +104,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'yml-with-folder').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('yml-with-folder').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -124,7 +119,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify folder-level CTA is visible', async () => { - await expect(collectionScope(page, 'bru-folder-with-js').getByTestId('add-request-cta-folder')).toBeVisible(); + await expect(locators.sidebar.collectionScope('bru-folder-with-js').getByTestId('add-request-cta-folder')).toBeVisible(); }); }); @@ -137,7 +132,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify folder-level CTA is visible', async () => { - await expect(collectionScope(page, 'yml-with-folder').getByTestId('add-request-cta-folder')).toBeVisible(); + await expect(locators.sidebar.collectionScope('yml-with-folder').getByTestId('add-request-cta-folder')).toBeVisible(); }); }); }); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index c71cab990df..ca3b4b0f9c3 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1,4 +1,5 @@ import { test, expect, Page, Locator, ElectronApplication, waitForReadyPage as waitForReadyPageImpl } from '../../../playwright'; +import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; import process from 'node:process'; import * as path from 'path'; import * as fs from 'fs'; @@ -121,8 +122,18 @@ const closeAllCollections = async (page) => { * @param collectionName - The name of the collection to open * @returns void */ +// sidebar is virtualized, opening a request lower in the list scrolls the collection header +// out of the viewport, and Virtuoso unmounts it once it passes the overscan. +// Reset the list to the top so the header row is rendered before we locate it. +const revealCollectionsTop = async (page: Page) => { + const scroller = page.getByTestId('sidebar-collections-scroller'); + if (!(await scroller.count())) return; + await scroller.evaluate((el) => el.scrollTo({ top: 0 })); +}; + const openCollection = async (page: Page, collectionName: string) => { await test.step(`Open collection "${collectionName}"`, async () => { + await revealCollectionsTop(page); await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click(); }); }; @@ -551,11 +562,10 @@ const deleteRequest = async (page, requestName: string, collectionName: string) // Click on the collection first to open it if it's closed await locators.sidebar.collection(collectionName).click(); - // Find the request within the collection's context - // Use the collection container (.collection-name) scoped to sidebar to scope the search - const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName }); - const collectionWrapper = collectionContainer.locator('..'); - const request = collectionWrapper.locator('.collection-item-name').filter({ hasText: requestName }); + const request = page + .locator(`[data-collection-id="${collectionSlug(collectionName)}"]`) + .locator('.collection-item-name') + .filter({ hasText: requestName }); await request.hover(); await request.locator('.menu-icon').click(); @@ -785,7 +795,7 @@ const createFolder = async ( // Scope to the parent so same-named folders in other collections don't trip strict mode. const parentScope = isCollection ? locators.sidebar.collectionScope(parentName) - : locators.sidebar.folder(parentName).locator('..'); + : locators.sidebar.folderScope(parentName); await expect(parentScope.locator('.collection-item-name').filter({ hasText: folderName })).toBeVisible(); }); }; @@ -1477,8 +1487,10 @@ const openRequest = async (page: Page, collectionName: string, requestName: stri await test.step(`Navigate to collection "${collectionName}" and open request "${requestName}"`, async () => { const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName }); await collectionContainer.click(); - const collectionWrapper = collectionContainer.locator('..'); - const request = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName }); + const request = page + .locator(`[data-collection-id="${collectionSlug(collectionName)}"]`) + .getByTestId('sidebar-collection-item-row') + .filter({ hasText: requestName }); if (!persist) { await request.click(); } else { @@ -1498,8 +1510,10 @@ const openfolder = async (page: Page, collectionName: string, folderName: string await test.step(`Open folder "${folderName}" in collection "${collectionName}"`, async () => { const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName }); await collectionContainer.click(); - const collectionWrapper = collectionContainer.locator('..'); - const folder = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: folderName }); + const folder = page + .locator(`[data-collection-id="${collectionSlug(collectionName)}"]`) + .getByTestId('sidebar-collection-item-row') + .filter({ hasText: folderName }); if (!persist) { await folder.click(); } else { @@ -1547,6 +1561,7 @@ const selectFolderScriptPaneTab = async (page: Page, tabName: 'pre-request' | 'p */ const openCollectionSettings = async (page: Page, collectionName: string, { persist = false } = {}) => { await test.step(`Open collection settings for "${collectionName}"`, async () => { + await revealCollectionsTop(page); const locators = buildCommonLocators(page); const collection = locators.sidebar.collection(collectionName); if (!persist) { @@ -1632,11 +1647,10 @@ const openFolderRequest = async (page: Page, collectionName: string, folderName: const { sidebar, tabs } = buildCommonLocators(page); const collectionRow = sidebar.collectionRow(collectionName); await collectionRow.click(); - const collectionWrapper = collectionRow.locator('..'); - const folder = collectionWrapper.locator('.collection-item-name').filter({ has: page.getByText(folderName, { exact: true }) }); + const folder = sidebar.collectionScope(collectionName).locator('.collection-item-name').filter({ has: page.getByText(folderName, { exact: true }) }); await folder.waitFor({ state: 'visible' }); await folder.click(); - const request = collectionWrapper.locator('.collection-item-name').filter({ has: page.getByText(requestName, { exact: true }) }); + const request = sidebar.folderScope(folderName).locator('.collection-item-name').filter({ has: page.getByText(requestName, { exact: true }) }); await request.waitFor({ state: 'visible' }); await request.click(); await expect(tabs.activeRequestTab()).toContainText(requestName); @@ -2700,7 +2714,7 @@ const createExampleFromSidebar = async (page: Page, requestName: string, example const openExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, index: number = 0) => { const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first(); - const requestBranch = requestRow.locator('..'); + const requestBranch = page.locator(`[data-parent-name="${requestName}"]`); const exampleRow = requestBranch .locator('.collection-item-name') .filter({ has: page.locator('.example-icon') }) @@ -2821,7 +2835,7 @@ const openRequestInFolder = async (page: Page, folderName: string, requestName: const { sidebar } = buildCommonLocators(page); await sidebar.folder(folderName).click(); - const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..'); + const folderWrapper = page.locator(`[data-parent-name="${folderName}"]`); const escapedName = requestName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const requestRow = folderWrapper.locator('.collection-item-name').filter({ has: page.locator('.item-name').filter({ hasText: new RegExp(`^${escapedName}$`) }) diff --git a/tests/utils/page/mounting.ts b/tests/utils/page/mounting.ts index 57f32071387..be725f1a853 100644 --- a/tests/utils/page/mounting.ts +++ b/tests/utils/page/mounting.ts @@ -1,4 +1,5 @@ import { test, expect, Page, ElectronApplication } from '../../../playwright'; +import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; /** * Collection tree item structure for assertions @@ -23,12 +24,12 @@ export const buildCollectionTreeLocators = (page: Page) => { has: page.locator('#sidebar-collection-name', { hasText: name }) }); - const itemScope = (collectionName?: string) => collectionName - ? collectionRow(collectionName).locator('..') - : page; + const collectionScope = (name: string) => page.locator(`[data-collection-id="${collectionSlug(name)}"]`); + const itemScope = (collectionName?: string) => collectionName ? collectionScope(collectionName) : page; return { - /** + collectionScope, + /** * Collection-level locators */ collection: { @@ -201,13 +202,7 @@ export const getCollectionItemCount = async ( collectionName: string ): Promise => { const locators = buildCollectionTreeLocators(page); - - // Get the parent wrapper that contains the collection and its items - const collectionWrapper = locators.collection.row(collectionName).locator('..'); - - // Count all collection items within this collection - const items = collectionWrapper.getByTestId('sidebar-collection-item-row'); - return await items.count(); + return await locators.item.allRows(collectionName).count(); }; /** @@ -223,95 +218,72 @@ export const getCollectionTreeStructure = async ( const locators = buildCollectionTreeLocators(page); return await test.step(`Get tree structure for collection "${collectionName}"`, async () => { - const collectionRow = locators.collection.row(collectionName); - - // Ensure collection is expanded - const isExpanded = await locators.collection.isExpanded(collectionName); - if (!isExpanded) { - await collectionRow.click(); + // Ensure the collection is expanded. + if (!(await locators.collection.isExpanded(collectionName))) { + await locators.collection.row(collectionName).click(); } - - // Wait for collection to finish mounting after expansion await waitForCollectionMount(page, collectionName); - // Collection structure: - // StyledWrapper > [collection-row, children-wrapper > inner-container > items] - // Get the sibling div that contains the children (not the collection row itself) - const collectionWrapper = collectionRow.locator('..'); - const childrenContainer = collectionWrapper.locator(':scope > div:not([data-testid="sidebar-collection-row"]) > div').first(); - - const items = await extractItemsFromContainer(page, childrenContainer, collectionName); + // Expand every folder so the whole subtree is present in the flat, virtualized list. + await expandAllFolders(collectionName, locators); + + // The sidebar is a flat, DFS-ordered list of rows. reconstruct the tree from each row's + // indent depth (number of `.indent-block` spacers). + const flat: FlatItem[] = []; + for (const row of await locators.item.allRows(collectionName).all()) { + const name = (await locators.item.getNameFromRow(row).innerText()).trim(); + const isFolder = (await locators.item.isFolderRow(row).count()) > 0; + const depth = await row.locator('.indent-block').count(); + let method: string | undefined; + if (!isFolder) { + const badge = row.locator('.mr-1 span').first(); + method = (await badge.count()) > 0 ? (await badge.innerText()).trim().toUpperCase() : undefined; + } + flat.push({ name, isFolder, depth, method }); + } - return { - name: collectionName, - items - }; + return { name: collectionName, items: buildTreeFromFlat(flat) }; }); }; -/** - * Helper function to extract items from a container (collection or folder). - */ -async function extractItemsFromContainer( - page: Page, - container: ReturnType, - collectionName?: string -): Promise { - const locators = buildCollectionTreeLocators(page); - const items: CollectionTreeItem[] = []; - - // Get direct child StyledWrappers, each contains one item - // Structure: container > StyledWrapper > [item-row, children-div?] - const childWrappers = container.locator(':scope > div:has([data-testid="sidebar-collection-item-row"])'); - const count = await childWrappers.count(); - - for (let i = 0; i < count; i++) { - const wrapper = childWrappers.nth(i); - const itemRow = wrapper.getByTestId('sidebar-collection-item-row').first(); - const itemName = (await locators.item.getNameFromRow(itemRow).innerText()).trim(); - - // Check if it's a folder by looking for folder chevron within this specific row - const isFolder = await locators.item.isFolderRow(itemRow).count() > 0; - - if (isFolder) { - // It's a folder - expand it via the chevron in this exact row to avoid - // matching same-named folders elsewhere in the tree. - const folderChevron = locators.item.isFolderRow(itemRow); - const rowIsExpanded = await itemRow.locator('.rotate-90').count() > 0; - if (!rowIsExpanded) { - await folderChevron.click(); - await expect.poll(async () => await itemRow.locator('.rotate-90').count() > 0).toBe(true); - } +type FlatItem = { name: string; isFolder: boolean; depth: number; method?: string }; - // Children are in a sibling div after the item row (within the same wrapper) - // Structure: wrapper > [item-row, children-container] - const childrenContainer = wrapper.locator(':scope > div:not([data-testid="sidebar-collection-item-row"])').first(); - const hasChildren = await childrenContainer.count() > 0; - const nestedItems = hasChildren ? await extractItemsFromContainer(page, childrenContainer, collectionName) : []; +/** Expand every collapsed folder in the collection. */ +async function expandAllFolders( + collectionName: string, + locators: ReturnType +): Promise { + const collapsedChevrons = () => + locators.item.allRows(collectionName).locator('[data-testid="folder-chevron"]:not(.rotate-90)'); + + // Expand the first collapsed folder until all folders are expanded. Pin the clicked chevron so + // re-resolving `.first()` after the click doesn't target a different row. + // Poll `rotate-90` to confirm the expansion. + while ((await collapsedChevrons().count()) > 0) { + const chevron = collapsedChevrons().first(); + const handle = await chevron.elementHandle(); + if (!handle) continue; + await chevron.click(); + await expect.poll(() => handle.evaluate((el) => el.classList.contains('rotate-90'))).toBe(true); + } +} - items.push({ - name: itemName, - type: 'folder', - items: nestedItems - }); +/** Rebuild the nested tree from a flat, DFS-ordered list of rows keyed by indent depth. */ +function buildTreeFromFlat(flat: FlatItem[]): CollectionTreeItem[] { + const root: CollectionTreeItem[] = []; + const stack: { depth: number; items: CollectionTreeItem[] }[] = [{ depth: 0, items: root }]; + for (const r of flat) { + while (stack.length > 1 && stack[stack.length - 1].depth >= r.depth) stack.pop(); + const parent = stack[stack.length - 1].items; + if (r.isFolder) { + const node: CollectionTreeItem = { name: r.name, type: 'folder', items: [] }; + parent.push(node); + stack.push({ depth: r.depth, items: node.items as CollectionTreeItem[] }); } else { - // It's a request - read the method badge from this exact row to avoid - // colliding with same-named requests elsewhere. - const methodBadge = itemRow.locator('.mr-1 span').first(); - let method = ''; - if (await methodBadge.count() > 0) { - method = (await methodBadge.innerText()).trim().toUpperCase(); - } - - items.push({ - name: itemName, - type: 'request', - method: method || undefined - }); + parent.push({ name: r.name, type: 'request', method: r.method }); } } - - return items; + return root; } /** @@ -367,7 +339,7 @@ export const waitForItemCount = async ( const locators = buildCollectionTreeLocators(page); await test.step(`Wait for ${expectedCount} items in collection "${collectionName}"`, async () => { - const collectionWrapper = locators.collection.row(collectionName).locator('..'); + const collectionWrapper = locators.collectionScope(collectionName); const items = collectionWrapper.getByTestId('sidebar-collection-item-row'); await expect(items).toHaveCount(expectedCount, { timeout }); @@ -382,7 +354,7 @@ export const waitForItemCount = async ( */ export const hasErrorItems = async (page: Page, collectionName: string): Promise => { const locators = buildCollectionTreeLocators(page); - const collectionWrapper = locators.collection.row(collectionName).locator('..'); + const collectionWrapper = locators.collectionScope(collectionName); // Look for error indicators (typically a red icon or error class) const errorIndicators = collectionWrapper.locator('.item-error, .error-indicator, [class*="error"]'); @@ -397,7 +369,7 @@ export const hasErrorItems = async (page: Page, collectionName: string): Promise */ export const getErrorItemNames = async (page: Page, collectionName: string): Promise => { const locators = buildCollectionTreeLocators(page); - const collectionWrapper = locators.collection.row(collectionName).locator('..'); + const collectionWrapper = locators.collectionScope(collectionName); const errorItems = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ has: page.locator('.item-error, .error-indicator, [class*="error"]') diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index 92a05910187..9747bb5230b 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -1,4 +1,5 @@ import { Page, expect, test } from '../../../playwright'; +import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; import { buildCommonLocators, buildSandboxLocators } from './locators'; /** @@ -155,17 +156,12 @@ export const openRunnerResultTimeline = async (page: Page, requestName: string) */ export const runFolder = async (page: Page, collectionName: string, folderPath: string[]) => { await test.step(`Run folder "${folderPath.join('/')}" in "${collectionName}"`, async () => { - // Scope to the specific collection by its DOM id (collection-) - const collectionId = `collection-${collectionName.replace(/\s+/g, '-').toLowerCase()}`; - const collectionContainer = page.locator(`#${collectionId}`); - await collectionContainer.waitFor({ state: 'visible', timeout: 5000 }); - - // Walk down the folder path, scoping each step to the previous folder's container. - // Each CollectionItem renders as a StyledWrapper div containing: - // - div.collection-item-name (the row with chevron, name, menu) - // - div (children container when expanded) - // We scope to the parent wrapper so the next folder lookup is unambiguous. - let scope = collectionContainer; + // Flat, virtualized sidebar: scope by `data-collection-id` / `data-parent-name` rather than DOM nesting. + const collectionScope = page.locator(`[data-collection-id="${collectionSlug(collectionName)}"]`); + await collectionScope.first().waitFor({ state: 'visible', timeout: 5000 }); + + let scope = collectionScope; + let targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[0] }).first(); for (const folderName of folderPath) { const row = scope.locator('.collection-item-name').filter({ hasText: folderName }).first(); await row.waitFor({ state: 'visible', timeout: 5000 }); @@ -177,12 +173,11 @@ export const runFolder = async (page: Page, collectionName: string, folderPath: await chevron.click(); } - // Scope to this folder's wrapper (parent of the row) for the next iteration - scope = row.locator('..'); + targetRow = row; + scope = page.locator(`[data-parent-name="${folderName}"]`); } - // The target folder row is the last one we found — hover to reveal menu - const targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[folderPath.length - 1] }).first(); + // The deepest folder row we found — hover to reveal its menu. await targetRow.hover(); // Click the menu icon diff --git a/tests/utils/page/sidebar/index.ts b/tests/utils/page/sidebar/index.ts index 0a594901de8..ef520c77ef2 100644 --- a/tests/utils/page/sidebar/index.ts +++ b/tests/utils/page/sidebar/index.ts @@ -1,4 +1,5 @@ import { Locator, Page } from '../../../../playwright'; +import { collectionSlug } from '../../../../packages/bruno-app/src/utils/collections/collectionSlug'; export type EmptyStateRequestType = 'http' | 'graphql' | 'grpc' | 'websocket'; @@ -12,7 +13,7 @@ export const buildSidebarLocators = (page: Page) => { const collectionRow = (name: string) => page.getByTestId('sidebar-collection-row').filter({ hasText: name }); const itemRow = (name: string) => page.getByTestId('sidebar-collection-item-row').filter({ has: itemByName(name) }); - const collectionScope = (name: string) => page.locator(`#collection-${name.replace(/\s+/g, '-').toLowerCase()}`); + const collectionScope = (name: string) => page.locator(`[data-collection-id="${collectionSlug(name)}"]`); return { collectionsContainer: () => page.getByTestId('collections'), @@ -21,11 +22,7 @@ export const buildSidebarLocators = (page: Page) => { request: (name: string) => page.locator('.collection-item-name').filter({ hasText: name }), collectionChevron: (name: string) => collectionRow(name).getByTestId('collection-chevron'), folderRequest: (folderName: string, requestName: string) => { - // Find the folder's collection-item-name, then navigate to its parent wrapper container (StyledWrapper), - // and search for the request within that container's descendants. - // Using .locator('..') gets the parent element of the folder's collection-item-name div. - const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..'); - return folderWrapper.locator('.collection-item-name').filter({ hasText: requestName }); + return page.locator(`[data-parent-name="${folderName}"]`).locator('.collection-item-name').filter({ hasText: requestName }); }, closeAllCollectionsButton: () => page.getByTestId('collections-header-actions-menu-close-all'), collectionRow, @@ -59,6 +56,8 @@ export const buildSidebarLocators = (page: Page) => { page.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName }).getByTestId('request-item-chevron'), example: (name: string) => page.getByTestId('sidebar-response-example-item').filter({ hasText: name }), collectionScope, + collectionScopeByUid: (collectionUid: string) => page.locator(`[data-collection-uid="${collectionUid}"]`), + folderScope: (folderName: string) => page.locator(`[data-parent-name="${folderName}"]`), scopedItem: function (collectionName: string, itemName: string) { return this.collectionScope(collectionName).locator('.item-name').and(page.getByTitle(itemName, { exact: true })); },