From 431ea02e16372fc7723d55ba26c21e5afe9aec47 Mon Sep 17 00:00:00 2001 From: prateek-bruno Date: Tue, 28 Apr 2026 11:51:58 +0530 Subject: [PATCH] feat: new selected list component for importing from git (#7813) --- .../src/components/Modal/StyledWrapper.js | 38 +++++--- .../components/SelectionList/StyledWrapper.js | 88 +++++++++++++++++ .../src/components/SelectionList/index.js | 74 ++++++++++++++ .../BulkImportCollectionLocation/index.js | 96 +++++-------------- .../Sidebar/CloneGitRespository/index.js | 35 +++---- packages/bruno-app/src/globalStyles.js | 12 +-- .../003-selection-list-viewport.spec.ts | 86 +++++++++++++++++ 7 files changed, 319 insertions(+), 110 deletions(-) create mode 100644 packages/bruno-app/src/components/SelectionList/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/SelectionList/index.js create mode 100644 tests/import/bulk-import/003-selection-list-viewport.spec.ts diff --git a/packages/bruno-app/src/components/Modal/StyledWrapper.js b/packages/bruno-app/src/components/Modal/StyledWrapper.js index a6a188245..327f51134 100644 --- a/packages/bruno-app/src/components/Modal/StyledWrapper.js +++ b/packages/bruno-app/src/components/Modal/StyledWrapper.js @@ -208,21 +208,35 @@ const Wrapper = styled.div` outline-offset: 2px; } - &:checked { + &:checked, + &:indeterminate { background: ${(props) => props.theme.button2.color.primary.bg}; border-color: ${(props) => props.theme.button2.color.primary.border}; + } - &::after { - content: ''; - position: absolute; - left: 4px; - top: 1px; - width: 5px; - height: 9px; - border: solid ${(props) => props.theme.button2.color.primary.text}; - border-width: 0 2px 2px 0; - transform: rotate(45deg); - } + &:checked::after, + &:indeterminate::after { + content: ''; + position: absolute; + } + + &:checked::after { + left: 4px; + top: 1px; + width: 5px; + height: 9px; + border: solid ${(props) => props.theme.button2.color.primary.text}; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + + &:indeterminate::after { + left: 2px; + top: 6px; + width: 10px; + height: 2px; + background: ${(props) => props.theme.button2.color.primary.text}; + border-radius: 2px; } } `; diff --git a/packages/bruno-app/src/components/SelectionList/StyledWrapper.js b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js new file mode 100644 index 000000000..c67b7a7d7 --- /dev/null +++ b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js @@ -0,0 +1,88 @@ +import styled from 'styled-components'; +import { transparentize } from 'polished'; + +const getListHeight = ({ $visibleRows, $rowHeight, $rowGap, $listPadding }) => { + const rowsHeight = $rowHeight * $visibleRows; + const gapsHeight = $rowGap * Math.max($visibleRows - 1, 0); + const paddingHeight = $listPadding * 2; + const bordersHeight = 2; + + return `${rowsHeight + gapsHeight + paddingHeight + bordersHeight}px`; +}; + +const StyledWrapper = styled.div` + .selection-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; + } + + .selection-title { + margin: 0; + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 600; + } + + .selection-toggle { + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; + color: ${(props) => props.theme.text}; + font-size: ${(props) => props.theme.font.size.md}; + font-weight: 400; + } + + .selection-toggle input[type='checkbox'] { + cursor: pointer; + margin-right: 0.5rem; + } + + .selection-list { + max-height: ${getListHeight}; + overflow-y: auto; + border: 1px solid ${(props) => transparentize(0.4, props.theme.border.border2)}; + border-radius: ${(props) => props.theme.border.radius.base}; + padding: ${(props) => `${props.$listPadding}px 0`}; + margin: 0; + list-style: none; + } + + .selection-item { + box-sizing: border-box; + display: flex; + align-items: center; + min-height: ${(props) => `${props.$rowHeight}px`}; + padding: 0.375rem 1rem; + cursor: pointer; + user-select: none; + font-size: ${(props) => props.theme.font.size.md}; + font-weight: 400; + } + + .selection-list li + li .selection-item { + margin-top: ${(props) => `${props.$rowGap}px`}; + } + + .selection-item input[type='checkbox'] { + accent-color: ${(props) => props.theme.workspace.accent}; + cursor: pointer; + margin-right: 0.75rem; + } + + .selection-path { + line-height: 1.2; + word-break: break-word; + } + + .selection-empty { + padding: 0.5rem; + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + font-style: italic; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/SelectionList/index.js b/packages/bruno-app/src/components/SelectionList/index.js new file mode 100644 index 000000000..2913e4803 --- /dev/null +++ b/packages/bruno-app/src/components/SelectionList/index.js @@ -0,0 +1,74 @@ +import React, { useRef, useEffect } from 'react'; +import StyledWrapper from './StyledWrapper'; + +const SelectionList = ({ + title, + items, + selectedItems, + onSelectAll, + onItemToggle, + getItemId, + renderItemLabel, + visibleRows = 8, + rowHeight = 30, + rowGap = 2, + listPadding = 8, + emptyMessage = 'No items found' +}) => { + const allSelected = items.length > 0 && selectedItems.length === items.length; + const someSelected = items.length > 0 && selectedItems.length > 0 && !allSelected; + const selectAllRef = useRef(null); + + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someSelected; + } + }, [someSelected]); + + return ( + +
+ {title} + +
+ +
+ ); +}; + +export default SelectionList; diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js index 5886aaae0..e9829272f 100644 --- a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js @@ -11,6 +11,7 @@ import InfoTip from 'components/InfoTip/index'; import Help from 'components/Help'; import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import Dropdown from 'components/Dropdown'; +import SelectionList from 'components/SelectionList'; import { postmanToBruno } from 'utils/importers/postman-collection'; import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection'; import { convertOpenapiToBruno } from 'utils/importers/openapi-collection'; @@ -181,9 +182,6 @@ export const BulkImportCollectionLocation = ({ const [selectedCollections, setSelectedCollections] = useState(importedCollection.map((col) => col.uid)); const [selectedEnvironments, setSelectedEnvironments] = useState(isBulkImport ? importedEnvironmentFromBulk.map((env) => env.uid) : []); - const allCollectionsSelected = selectedCollections.length === importedCollection.length; - const allEnvironmentsSelected = selectedEnvironments.length === importedEnvironment.length; - // Sort collections to show selected items first, then unselected items // This helps users see their selections at the top of the list const sortedCollections = useMemo(() => { @@ -443,7 +441,7 @@ export const BulkImportCollectionLocation = ({ useEffect(() => { if (!isElectron()) { - return () => {}; + return () => { }; } const { ipcRenderer } = window; @@ -667,79 +665,33 @@ export const BulkImportCollectionLocation = ({ ) : ( <>
-
- Collections ({importedCollection.length}) - -
-
- {importedCollection.length === 0 && ( -
- No collections found -
- )} - {sortedCollections.map((collection) => ( - - ))} -
+ collection.uid} + renderItemLabel={(collection) => collection.name} + visibleRows={5} + emptyMessage="No collections found" + />
{importType === 'bulk' && ( <>
-
- Environments ({importedEnvironment.length}) - -
-
- {importedEnvironment.length === 0 && ( -
- No environments found -
- )} - {sortedEnvironments.map((env) => ( - - ))} -
+ env.uid} + renderItemLabel={(env) => env.name} + visibleRows={5} + emptyMessage="No environments found" + />
diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js index 40bcdc8e8..151bc02d9 100644 --- a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js +++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js @@ -15,6 +15,7 @@ import Portal from 'components/Portal'; import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons'; import { uuid } from 'utils/common/index'; import StyledWrapper from './StyledWrapper'; +import SelectionList from 'components/SelectionList'; import { getRepoNameFromUrl } from 'utils/git'; import GitNotFoundModal from 'components/Git/GitNotFoundModal/index'; import get from 'lodash/get'; @@ -164,6 +165,10 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null ); }; + const handleSelectAllCollections = (e) => { + setSelectedCollectionPaths(e.target.checked ? [...collectionPaths] : []); + }; + const getRelativePath = (fullPath, pathname) => { let relativePath = path.relative(fullPath, pathname); const { dir, name } = path.parse(relativePath); @@ -338,26 +343,16 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
)} {collectionPaths.length > 0 && ( - <> -

- {collectionPaths.length} bruno collections found. Please select the collections to open: -

- - + collection} + renderItemLabel={(collection) => getRelativePath(formik.values.collectionLocation, collection)} + visibleRows={8} + /> )} )} diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js index 84e7e35b8..c947dc3c7 100644 --- a/packages/bruno-app/src/globalStyles.js +++ b/packages/bruno-app/src/globalStyles.js @@ -210,10 +210,13 @@ const GlobalStyle = createGlobalStyle` } } - // Utility class for scrollbars that are hidden by default and shown on hover + // Utility class for scrollbars that are hidden by default and shown on hover. + // scrollbar-width/color: auto is required so macOS Chromium uses + // ::-webkit-scrollbar instead of standard overlay rendering, which + // auto-hides regardless of CSS. .scrollbar-hover { - scrollbar-width: thin; - scrollbar-color: transparent transparent; + scrollbar-width: auto !important; + scrollbar-color: auto; &::-webkit-scrollbar { width: 5px; @@ -228,13 +231,10 @@ const GlobalStyle = createGlobalStyle` background-color: transparent; border-radius: 14px; border: 3px solid transparent; - background-clip: content-box; transition: background-color 0.2s ease; } &:hover { - scrollbar-color: ${(props) => props.theme.scrollbar.color} transparent; - &::-webkit-scrollbar-thumb { background-color: ${(props) => props.theme.scrollbar.color}; } diff --git a/tests/import/bulk-import/003-selection-list-viewport.spec.ts b/tests/import/bulk-import/003-selection-list-viewport.spec.ts new file mode 100644 index 000000000..308d91728 --- /dev/null +++ b/tests/import/bulk-import/003-selection-list-viewport.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '../../../playwright'; +import type { Locator } from '@playwright/test'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { closeAllCollections } from '../../utils/page'; + +const getViewportCollectionName = (index: number) => `Viewport Collection ${String(index).padStart(2, '0')}`; + +const getFullyVisibleRowNames = async (list: Locator) => { + return list.evaluate((node) => { + const listRect = node.getBoundingClientRect(); + const items = Array.from(node.querySelectorAll('.selection-item')); + + return items + .filter((item) => { + const rect = item.getBoundingClientRect(); + return rect.top >= listRect.top && rect.bottom <= listRect.bottom; + }) + .map((item) => item.textContent?.trim()) + .filter(Boolean); + }); +}; + +test.describe('Bulk Import Selection List', () => { + const testDataDir = path.join(__dirname, '../test-data'); + const expectedVisibleRows = 5; + + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('shows the configured number of visible rows and reveals later rows when scrolled', async ({ page, createTmpDir }) => { + const sourceFile = path.join(testDataDir, 'sample-postman.json'); + const tempDir = await createTmpDir('bulk-import-selection-list'); + const sourceContent = JSON.parse(await fs.readFile(sourceFile, 'utf-8')); + + const importFiles: string[] = []; + for (let index = 1; index <= 10; index++) { + const filePath = path.join(tempDir, `sample-postman-${index}.json`); + const fileContent = { + ...sourceContent, + info: { + ...sourceContent.info, + name: getViewportCollectionName(index) + } + }; + + await fs.writeFile(filePath, JSON.stringify(fileContent, null, 2), 'utf-8'); + importFiles.push(filePath); + } + + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + const importModal = page.getByRole('dialog'); + await importModal.waitFor({ state: 'visible' }); + await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection'); + + await page.setInputFiles('input[type="file"]', importFiles); + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + const bulkImportModal = page.getByRole('dialog'); + await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import'); + await expect(bulkImportModal.getByText('Collections (10)')).toBeVisible(); + + const collectionList = bulkImportModal.locator('.selection-list').first(); + await expect(collectionList).toBeVisible(); + + const initialVisibleRows = await getFullyVisibleRowNames(collectionList); + expect(initialVisibleRows).toHaveLength(expectedVisibleRows); + expect(initialVisibleRows[0]).toBe(getViewportCollectionName(1)); + expect(initialVisibleRows[expectedVisibleRows - 1]).toBe(getViewportCollectionName(expectedVisibleRows)); + expect(initialVisibleRows).not.toContain(getViewportCollectionName(expectedVisibleRows + 1)); + + await collectionList.evaluate((list) => { + list.scrollTop = list.scrollHeight; + }); + + await expect(async () => { + const scrolledVisibleRows = await getFullyVisibleRowNames(collectionList); + expect(scrolledVisibleRows).toHaveLength(expectedVisibleRows); + expect(scrolledVisibleRows).toContain(getViewportCollectionName(9)); + expect(scrolledVisibleRows).toContain(getViewportCollectionName(10)); + }).toPass({ timeout: 5000 }); + }); +});