mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: new selected list component for importing from git (#7813)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
74
packages/bruno-app/src/components/SelectionList/index.js
Normal file
74
packages/bruno-app/src/components/SelectionList/index.js
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
86
tests/import/bulk-import/003-selection-list-viewport.spec.ts
Normal file
86
tests/import/bulk-import/003-selection-list-viewport.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user