feat: new selected list component for importing from git (#7813)

This commit is contained in:
prateek-bruno
2026-04-28 11:51:58 +05:30
committed by GitHub
parent a04d434f76
commit 431ea02e16
7 changed files with 319 additions and 110 deletions

View File

@@ -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;
}
}
`;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper
$visibleRows={visibleRows}
$rowHeight={rowHeight}
$rowGap={rowGap}
$listPadding={listPadding}
>
<div className="selection-toolbar">
<span className="selection-title">{title}</span>
<label className="selection-toggle">
<input
ref={selectAllRef}
className="checkbox"
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
/>
Select All
</label>
</div>
<ul className="selection-list scrollbar-hover">
{items.length === 0 && (
<li className="selection-empty">{emptyMessage}</li>
)}
{items.map((item) => {
const itemId = getItemId(item);
const isSelected = selectedItems.includes(itemId);
return (
<li key={itemId}>
<label className="selection-item">
<input
type="checkbox"
checked={isSelected}
onChange={() => onItemToggle(itemId)}
/>
<span className="selection-path">{renderItemLabel(item)}</span>
</label>
</li>
);
})}
</ul>
</StyledWrapper>
);
};
export default SelectionList;

View File

@@ -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 = ({
) : (
<>
<div className="mb-6">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Collections ({importedCollection.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allCollectionsSelected}
onChange={handleSelectAllCollections}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2">
{importedCollection.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No collections found
</div>
)}
{sortedCollections.map((collection) => (
<label
key={collection.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer justify-between"
>
<div className="flex items-center flex-1">
<input
type="checkbox"
checked={selectedCollections.includes(collection.uid)}
onChange={() => handleCollectionToggle(collection.uid)}
className="mr-3"
/>
<span>{collection.name}</span>
</div>
</label>
))}
</div>
<SelectionList
title={`Collections (${importedCollection.length})`}
items={sortedCollections}
selectedItems={selectedCollections}
onSelectAll={handleSelectAllCollections}
onItemToggle={handleCollectionToggle}
getItemId={(collection) => collection.uid}
renderItemLabel={(collection) => collection.name}
visibleRows={5}
emptyMessage="No collections found"
/>
</div>
{importType === 'bulk' && (
<>
<div className="mb-4">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Environments ({importedEnvironment.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allEnvironmentsSelected}
onChange={handleSelectAllEnvironments}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{importedEnvironment.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No environments found
</div>
)}
{sortedEnvironments.map((env) => (
<label
key={env.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer"
>
<input
type="checkbox"
checked={selectedEnvironments.includes(env.uid)}
onChange={() => handleEnvironmentToggle(env.uid)}
className="mr-3"
/>
<span>{env.name}</span>
</label>
))}
</div>
<SelectionList
title={`Environments (${importedEnvironment.length})`}
items={sortedEnvironments}
selectedItems={selectedEnvironments}
onSelectAll={handleSelectAllEnvironments}
onItemToggle={handleEnvironmentToggle}
getItemId={(env) => env.uid}
renderItemLabel={(env) => env.name}
visibleRows={5}
emptyMessage="No environments found"
/>
</div>
<div className="mb-6">

View File

@@ -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
</div>
)}
{collectionPaths.length > 0 && (
<>
<h3 className="text-sm mb-2">
{collectionPaths.length} bruno collections found. Please select the collections to open:
</h3>
<ul>
{collectionPaths.map((collection) => (
<li key={collection} className="mb-2">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedCollectionPaths.includes(collection)}
onChange={() => handleCollectionSelect(collection)}
className="form-checkbox"
/>
<span>{getRelativePath(formik.values.collectionLocation, collection)}</span>
</label>
</li>
))}
</ul>
</>
<SelectionList
title={`Collections (${collectionPaths.length})`}
items={collectionPaths}
selectedItems={selectedCollectionPaths}
onSelectAll={handleSelectAllCollections}
onItemToggle={handleCollectionSelect}
getItemId={(collection) => collection}
renderItemLabel={(collection) => getRelativePath(formik.values.collectionLocation, collection)}
visibleRows={8}
/>
)}
</div>
)}

View File

@@ -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};
}

View File

@@ -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 });
});
});