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/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index 74cd2989d03..f9235dbf96c 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -260,20 +260,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; } } @@ -315,7 +316,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/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 83% 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 552d0df1310..2bfa839040e 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 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'; @@ -27,27 +26,25 @@ import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/ 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, expandItem, collapseItem } 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, @@ -55,7 +52,6 @@ import { isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; import { isEqual } from 'lodash'; -import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; import { canCollectionItemBeDropped, determineCollectionItemDrop, @@ -64,7 +60,6 @@ import { getSortedDraggedItems, isCollectionItemCollapsed } 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'; @@ -76,7 +71,18 @@ import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; import { startBlockedDragTracking } from 'utils/dragBlockedCursor'; import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections/index'; -const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu, isItemMultiDragDisabled, multiDragCollections, multiDragItems: multiDragItemsForSelection }) => { +const CollectionItemRow = ({ + item, + depth, + collectionUid, + collectionPathname, + searchText, + openBulkMenu, + children, + isItemMultiDragDisabled, + multiDragCollections, + multiDragItems: multiDragItemsForSelection +}) => { const { dropdownContainerRef } = useSidebarAccordion(); const selectorInput = { itemUid: item.uid, @@ -125,6 +131,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 = Boolean(item.examplesExpanded); const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : isCollectionItemCollapsed(item); @@ -183,17 +190,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, @@ -387,12 +383,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o const handleExamplesCollapse = (e) => { e.stopPropagation(); e.preventDefault(); - dispatch( - (isCollectionItemCollapsed(item) ? expandItem : collapseItem)({ - itemUid: item.uid, - collectionUid: collectionUid - }) - ); + dispatch(toggleRequestExamples({ collectionUid, itemUid: item.uid })); }; // prevent the parent's double-click handler from firing @@ -414,7 +405,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 = () => { @@ -589,11 +580,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); @@ -646,14 +632,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 !== '') @@ -807,7 +785,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' && !itemIsCollapsed && 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 8b1a4d25e0d..31f4bdb2cb4 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; 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 fffa22a7d94..f9144d73fc1 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 @@ -15,7 +15,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'; @@ -25,7 +24,7 @@ import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext' import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; import { startBlockedDragTracking } from 'utils/dragBlockedCursor'; -const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isParentDragDisabled, parentMultiDragItems }) => { +const ExampleItem = ({ example, item, collection, depth, searchText, openBulkMenu, isItemMultiDragDisabled, multiDragCollections, multiDragItems }) => { const { dropdownContainerRef } = useSidebarAccordion(); const dispatch = useDispatch(); const activeTabUid = useSelector((state) => state.tabs?.activeTabUid); @@ -42,6 +41,15 @@ const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isPa const isMultiSelected = isSelected && selectedSidebarUids.length > 1; const handleSelectionClick = useSidebarSelectionClick({ uid: example.uid, searchText }); + // In the flat/virtualized sidebar an example row is a sibling of its parent request, + // not a child, so we derive the parent request's multi-drag state here rather than + // receiving it as props from the parent (as the nested tree did on main). + const parentIsSelected = selectedSidebarUids.includes(item.uid); + const parentIsMultiSelected = parentIsSelected && selectedSidebarUids.length > 1; + const parentMultiDragItems = parentIsMultiSelected ? multiDragItems : null; + const isParentRedirectedToCollectionDrag = parentIsMultiSelected && multiDragCollections?.length > 0; + const isParentDragDisabled = parentIsMultiSelected && isItemMultiDragDisabled && !isParentRedirectedToCollectionDrag; + const isRedirectedToRequestDrag = isMultiSelected && selectedSidebarUids.includes(item.uid) && !isParentDragDisabled; const [, drag, dragPreview] = useDrag({ @@ -60,8 +68,9 @@ const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isPa drag(exampleRef); dragPreview(getEmptyImage(), { captureDraggingState: true }); - // 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); @@ -97,16 +106,6 @@ const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isPa 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 0a27517e6d5..ff476462703 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 } 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 { @@ -35,27 +34,24 @@ 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'; @@ -63,11 +59,7 @@ import CreateMockServerModal from 'components/MockServer/CreateMockServerModal'; import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; import { startBlockedDragTracking } from 'utils/dragBlockedCursor'; -// 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, isCollectionMultiDragDisabled, isItemMultiDragDisabled, multiDragCollections, multiDragItems: multiDragItemsForSelection }) => { +const CollectionRow = ({ collection, searchText, openBulkMenu, children, isCollectionMultiDragDisabled, multiDragCollections }) => { const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER); const { dropdownContainerRef } = useSidebarAccordion(); const [showNewFolderModal, setShowNewFolderModal] = useState(false); @@ -82,13 +74,9 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra 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 { hasCopiedItems } = useSelector((state) => state.app.clipboard); @@ -379,31 +367,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra 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; @@ -422,18 +385,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra } ); - // 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', @@ -584,7 +535,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra ]; return ( - + {showNewRequestModal && setShowNewRequestModal(false)} />} {showNewFolderModal && setShowNewFolderModal(false)} />} {showNewAppModal && setShowNewAppModal(false)} />} @@ -663,41 +614,9 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra )} -
- {!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/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..1a1b4537dcc --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -0,0 +1,133 @@ +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, isCollectionMultiDragDisabled, isItemMultiDragDisabled, 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.isCollectionMultiDragDisabled === next.isCollectionMultiDragDisabled + && prev.isItemMultiDragDisabled === next.isItemMultiDragDisabled + && 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 b0e0ec2f31c..11fdbd5fe6d 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -1,13 +1,14 @@ -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'; @@ -16,7 +17,9 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis const [searchText, setSearchText] = useState(''); const { collections, collectionSortOrder, selectedSidebarUids } = useSelector((state) => state.collections); const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const dispatch = useDispatch(); + const virtuosoRef = useRef(null); const { openBulkMenu, menuProps } = useBulkActionsMenu(); @@ -31,6 +34,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] @@ -58,10 +77,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) { @@ -85,34 +121,41 @@ 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/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index ea7901ac677..9ec25fb0a62 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/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 81f84b0a5d5..4869bd7ae1b 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; @@ -1437,6 +1423,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 d7e87fdd610..4dc9ee63a79 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') }) @@ -2835,7 +2849,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 })); },