feat: import modal revamp (#8121)

This commit is contained in:
prateek-bruno
2026-05-28 15:58:22 +05:30
committed by GitHub
parent 4ee9a75465
commit b43a5e6e0a
19 changed files with 822 additions and 233 deletions

View File

@@ -28,6 +28,7 @@ const ModalFooter = ({
confirmDisabled,
hideCancel,
hideFooter,
footerLeft,
confirmButtonColor = 'primary',
dataTestId = 'modal'
}) => {
@@ -39,24 +40,27 @@ const ModalFooter = ({
}
return (
<div className="flex justify-end p-4 bruno-modal-footer">
<span className={hideCancel ? 'hidden' : 'mr-2'}>
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
{cancelText}
</Button>
</span>
<span>
<Button
type="submit"
color={confirmButtonColor}
disabled={confirmDisabled}
onClick={handleSubmit}
className="submit"
data-testid={`${dataTestId}-submit-btn`}
>
{confirmText}
</Button>
</span>
<div className="flex justify-between items-center p-4 bruno-modal-footer">
<div>{footerLeft}</div>
<div className="flex justify-end">
<span className={hideCancel ? 'hidden' : 'mr-2'}>
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
{cancelText}
</Button>
</span>
<span>
<Button
type="submit"
color={confirmButtonColor}
disabled={confirmDisabled}
onClick={handleSubmit}
className="submit"
data-testid={`${dataTestId}-submit-btn`}
>
{confirmText}
</Button>
</span>
</div>
</div>
);
};
@@ -74,6 +78,7 @@ const Modal = ({
hideCancel,
hideFooter,
hideClose,
footerLeft,
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
@@ -152,6 +157,7 @@ const Modal = ({
confirmDisabled={confirmDisabled}
hideCancel={hideCancel}
hideFooter={hideFooter}
footerLeft={footerLeft}
confirmButtonColor={confirmButtonColor}
dataTestId={dataTestId}
/>

View File

@@ -0,0 +1,14 @@
import styled from 'styled-components';
const SelectionFooter = styled.div`
color: ${(props) => props.theme.colors.text.subtext2};
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
line-height: 1.25rem;
span {
color: ${(props) => props.theme.primary.solid};
}
`;
export default SelectionFooter;

View File

@@ -1,88 +1,222 @@
import styled from 'styled-components';
import { transparentize } from 'polished';
import { SELECTION_LIST_MAX_WIDTH } from './constants';
const getListHeight = ({ $visibleRows, $rowHeight, $rowGap, $listPadding }) => {
const getListHeight = ({ $visibleRows, $rowHeight, $rowGap }) => {
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`;
return `${rowsHeight + gapsHeight}px`;
};
const StyledWrapper = styled.div`
box-sizing: border-box;
width: 100%;
max-width: ${(props) => props.$maxWidth || SELECTION_LIST_MAX_WIDTH};
min-width: 0;
.selection-heading {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.5rem;
font-size: ${(props) => props.theme.font.size.base};
line-height: 1.25rem;
}
.selection-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
min-height: 1.25rem;
padding: 0 0.25rem;
border: 1px solid ${(props) => (props.theme.mode === 'dark'
? props.theme.workspace.button.bg
: props.theme.border.border1)};
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => (props.theme.mode === 'dark'
? props.theme.overlay.overlay0
: props.theme.background.surface0)};
color: ${(props) => props.theme.text};
font-weight: 500;
}
.selection-toolbar {
width: 100%;
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;
color: ${(props) => props.theme.table.thead.color};
}
.selection-panel {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
overflow: hidden;
border: 1px solid ${(props) => (props.theme.mode === 'dark' ? props.theme.border.border1 : props.theme.border.border0)};
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.5rem;
}
.selection-search {
box-sizing: border-box;
display: inline-flex;
flex: 1 1 auto;
align-items: center;
min-width: 0;
min-height: 1.75rem;
gap: 0.25rem;
border: 1px solid ${(props) => (props.theme.mode === 'dark' ? props.theme.border.border1 : props.theme.border.border0)};
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.25rem 0.5rem;
color: ${(props) => props.theme.colors.text.subtext1};
}
.selection-search input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 400;
line-height: 1.25rem;
}
.selection-search input::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
.selection-toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
flex: 0 0 auto;
cursor: pointer;
user-select: none;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.md};
font-weight: 400;
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
line-height: 1.25rem;
}
.selection-toggle input[type='checkbox'] {
.selection-toggle input[type='checkbox'],
.selection-item input[type='checkbox'] {
cursor: pointer;
margin-right: 0.5rem;
margin: 0;
}
.selection-list {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: ${(props) => `${props.$rowGap}px`};
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`};
overflow-x: hidden;
scrollbar-gutter: stable;
padding: 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 {
display: block;
width: 100%;
}
.selection-list li + li .selection-item {
margin-top: ${(props) => `${props.$rowGap}px`};
.selection-item {
box-sizing: border-box;
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr);
align-items: start;
width: 100%;
gap: 0.375rem;
padding: 0.25rem 0;
background: transparent;
border-radius: ${(props) => props.theme.border.radius.base};
cursor: pointer;
user-select: none;
}
.selection-item input[type='checkbox'] {
accent-color: ${(props) => props.theme.workspace.accent};
cursor: pointer;
margin-right: 0.75rem;
justify-self: center;
align-self: start;
margin-top: 0.275rem;
}
.selection-path {
line-height: 1.2;
word-break: break-word;
.selection-content {
display: flex;
flex-direction: column;
justify-content: flex-start;
min-width: 0;
overflow: hidden;
gap: 0;
}
.selection-item-title {
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
font-weight: 600;
line-height: 1.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selection-item-description {
display: -webkit-box;
min-width: 0;
width: 100%;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
line-height: 1.25rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: ${(props) => props.theme.colors.text.subtext1};
overflow-wrap: anywhere;
}
.selection-empty {
padding: 0.5rem;
box-sizing: border-box;
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr);
align-items: center;
width: 100%;
gap: 0.375rem;
padding: 0.25rem 0;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
font-style: italic;
font-weight: 400;
}
.selection-empty-message {
grid-column: 2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selection-selected-count {
margin-top: 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,2 @@
export const SELECTION_LIST_MAX_WIDTH = '720px';
export const IMPORT_COLLECTION_SELECTION_WIDTH = '600px';

View File

@@ -1,5 +1,13 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import { IconSearch } from '@tabler/icons';
import { search } from 'fast-fuzzy';
import StyledWrapper from './StyledWrapper';
import { SELECTION_LIST_MAX_WIDTH } from './constants';
import SelectionFooter from 'components/SelectionFooter';
export { IMPORT_COLLECTION_SELECTION_WIDTH } from './constants';
const normalizePath = (value) => value.replace(/\\/g, '/');
const SelectionList = ({
title,
@@ -8,16 +16,39 @@ const SelectionList = ({
onSelectAll,
onItemToggle,
getItemId,
renderItemLabel,
renderItemTitle,
renderItemDescription,
searchPlaceholder,
visibleRows = 8,
rowHeight = 30,
rowGap = 2,
listPadding = 8,
emptyMessage = 'No items found'
rowHeight = 40,
rowGap = 4,
emptyMessage = 'No items found',
maxWidth = SELECTION_LIST_MAX_WIDTH,
showSelectedCount = false,
dataTestId
}) => {
const allSelected = items.length > 0 && selectedItems.length === items.length;
const someSelected = items.length > 0 && selectedItems.length > 0 && !allSelected;
const [searchText, setSearchText] = useState('');
const selectAllRef = useRef(null);
const trimmedSearchText = searchText.trim();
const matchedItems = trimmedSearchText ? search(trimmedSearchText, items, {
keySelector: (item) => [
renderItemTitle(item),
renderItemDescription ? renderItemDescription(item) : null
]
.filter(Boolean)
.join(' ')
}) : items;
const filteredEntries = matchedItems.map((item) => ({ item, itemId: getItemId(item) }));
const filteredItemIds = filteredEntries.map(({ itemId }) => itemId);
const selectedFilteredItemCount = filteredItemIds.filter((itemId) => selectedItems.includes(itemId)).length;
const allSelected = filteredItemIds.length > 0 && selectedFilteredItemCount === filteredItemIds.length;
const someSelected = selectedFilteredItemCount > 0 && !allSelected;
const showFilteredEmptyState = items.length > 0 && filteredEntries.length === 0;
const listRows = items.length > 0 ? Math.min(items.length, visibleRows) : 1;
const handleSelectAll = (event) => {
onSelectAll(event, filteredItemIds);
};
useEffect(() => {
if (selectAllRef.current) {
@@ -25,48 +56,99 @@ const SelectionList = ({
}
}, [someSelected]);
const renderItemContent = (item) => {
const itemTitle = renderItemTitle(item);
const description = renderItemDescription ? renderItemDescription(item) : null;
return (
<>
<span className="selection-item-title">{itemTitle}</span>
{description && (
<span
className="selection-item-description"
title={typeof description === 'string' ? description : undefined}
>
{typeof description === 'string' ? normalizePath(description) : description}
</span>
)}
</>
);
};
return (
<StyledWrapper
$visibleRows={visibleRows}
$maxWidth={maxWidth}
$visibleRows={listRows}
$rowHeight={rowHeight}
$rowGap={rowGap}
$listPadding={listPadding}
data-testid={dataTestId}
>
<div className="selection-toolbar">
<div className="selection-heading" data-testid="selection-heading">
<span className="selection-title">{title}</span>
<label className="selection-toggle">
<input
ref={selectAllRef}
className="checkbox"
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
/>
Select All
</label>
<span className="selection-count" data-testid="selection-count">{items.length}</span>
</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>
<div className="selection-panel">
<div className="selection-toolbar">
<label className="selection-search">
<IconSearch size={16} strokeWidth={1.5} />
<input
data-testid="selection-search-input"
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={searchPlaceholder || `Search ${title}`}
/>
</label>
<label className="selection-toggle" data-testid="selection-select-all-toggle">
<input
ref={selectAllRef}
className="checkbox"
type="checkbox"
checked={allSelected}
onChange={handleSelectAll}
/>
Select all
</label>
</div>
<ul className="selection-list scrollbar-hover" data-testid="selection-list">
{items.length === 0 && (
<li className="selection-empty">
<span className="selection-empty-message">{emptyMessage}</span>
</li>
);
})}
</ul>
)}
{showFilteredEmptyState && (
<li className="selection-empty">
<span className="selection-empty-message">
{`No matching ${typeof title === 'string' ? title.toLowerCase() : 'items'} found`}
</span>
</li>
)}
{filteredEntries.map(({ item, itemId }) => {
const isSelected = selectedItems.includes(itemId);
return (
<li key={itemId}>
<label className="selection-item">
<input
className="checkbox"
type="checkbox"
checked={isSelected}
onChange={() => onItemToggle(itemId)}
/>
<span className="selection-content">
{renderItemContent(item)}
</span>
</label>
</li>
);
})}
</ul>
</div>
{showSelectedCount && (
<SelectionFooter className="selection-selected-count">
<span>{selectedItems.length}</span> of {items.length} selected
</SelectionFooter>
)}
</StyledWrapper>
);
};

View File

@@ -233,13 +233,19 @@ export const BulkImportCollectionLocation = ({
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
);
};
const handleSelectAllCollections = (e) => {
setSelectedCollections(e.target.checked ? importedCollection.map((col) => col.uid) : []);
const handleSelectAllCollections = (e, filteredCollectionUids) => {
setSelectedCollections((prevSelected) => (
e.target.checked
? Array.from(new Set([...prevSelected, ...filteredCollectionUids]))
: prevSelected.filter((uid) => !filteredCollectionUids.includes(uid))
));
};
const handleSelectAllEnvironments = (e) => {
setSelectedEnvironments(
e.target.checked ? importedEnvironment.map((env) => env.uid) : []
);
const handleSelectAllEnvironments = (e, filteredEnvironmentUids) => {
setSelectedEnvironments((prevSelected) => (
e.target.checked
? Array.from(new Set([...prevSelected, ...filteredEnvironmentUids]))
: prevSelected.filter((uid) => !filteredEnvironmentUids.includes(uid))
));
};
const onDropdownCreate = (ref) => {
@@ -664,33 +670,44 @@ export const BulkImportCollectionLocation = ({
</>
) : (
<>
<div className="mb-6">
<div className="w-full mb-6">
<SelectionList
title={`Collections (${importedCollection.length})`}
dataTestId="selection-section-collections"
title="Collections"
searchPlaceholder="Search Collections"
items={sortedCollections}
selectedItems={selectedCollections}
onSelectAll={handleSelectAllCollections}
onItemToggle={handleCollectionToggle}
getItemId={(collection) => collection.uid}
renderItemLabel={(collection) => collection.name}
renderItemTitle={(collection) => collection.name}
renderItemDescription={(collection) => collection._fileData?.file?.name}
visibleRows={5}
rowHeight={isMultipleImport ? 60 : 30}
rowGap={4}
emptyMessage="No collections found"
showSelectedCount={true}
/>
</div>
{importType === 'bulk' && (
<>
<div className="mb-4">
<div className="w-full mb-6">
<SelectionList
title={`Environments (${importedEnvironment.length})`}
dataTestId="selection-section-environments"
title="Environments"
searchPlaceholder="Search Environments"
items={sortedEnvironments}
selectedItems={selectedEnvironments}
onSelectAll={handleSelectAllEnvironments}
onItemToggle={handleEnvironmentToggle}
getItemId={(env) => env.uid}
renderItemLabel={(env) => env.name}
visibleRows={5}
renderItemTitle={(env) => env.name}
visibleRows={4}
rowHeight={30}
rowGap={4}
emptyMessage="No environments found"
showSelectedCount={true}
/>
</div>

View File

@@ -1,6 +1,11 @@
import styled from 'styled-components';
import { IMPORT_COLLECTION_SELECTION_WIDTH } from 'components/SelectionList/constants';
const StyledWrapper = styled.div`
width: ${IMPORT_COLLECTION_SELECTION_WIDTH};
max-width: 100%;
min-width: 0;
box-sizing: border-box;
.info-box {
background-color: ${(props) => props.theme.background.mantle};
color: ${(props) => props.theme.text};
@@ -13,6 +18,32 @@ const StyledWrapper = styled.div`
max-height: 150px;
overflow-y: auto;
}
.clone-progress-steps {
margin-bottom: 0.5rem;
}
.clone-step-error-icon {
color: ${(props) => props.theme.status.danger.text};
}
.clone-step-progress-icon {
color: ${(props) => props.theme.status.warning.text};
}
.scan-warning {
color: ${(props) => props.theme.status.warning.text};
background-color: ${(props) => props.theme.status.warning.background};
border: 1px solid ${(props) => props.theme.status.warning.border};
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.375rem 0.5rem;
font-size: ${(props) => props.theme.font.size.sm};
}
.scan-warning-icon {
color: ${(props) => props.theme.status.warning.text};
flex-shrink: 0;
}
`;
export default StyledWrapper;

View File

@@ -10,18 +10,23 @@ import {
} from 'providers/ReduxStore/slices/collections/actions';
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
import Modal from 'components/Modal';
import path from 'utils/common/path';
import SelectionFooter from 'components/SelectionFooter';
import path, { getRelativePath } from 'utils/common/path';
import Portal from 'components/Portal';
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
import { IconRefresh, IconAlertCircle, IconBrandGit } from '@tabler/icons';
import { uuid } from 'utils/common/index';
import StyledWrapper from './StyledWrapper';
import SelectionList from 'components/SelectionList';
import Button from 'ui/Button';
import { getRepoNameFromUrl } from 'utils/git';
import GitNotFoundModal from 'components/Git/GitNotFoundModal/index';
import SkippedPathsWarning from 'components/SkippedPathsWarning';
import toast from 'react-hot-toast';
import get from 'lodash/get';
const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null }) => {
const [collectionPaths, setCollectionPaths] = useState([]);
const [skippedCollectionPaths, setSkippedCollectionPaths] = useState([]);
const [selectedCollectionPaths, setSelectedCollectionPaths] = useState([]);
const [processUid, setProcessUid] = useState(uuid());
const [steps, setSteps] = useState([]);
@@ -69,6 +74,7 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
};
const cloneFinished = () => {
toast.success('Repository cloned successfully');
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone'
@@ -100,6 +106,7 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
};
const scanFinished = () => {
toast.success('Repository scanned successfully');
setSteps((prev) =>
prev.map((step) =>
step.step === 'scan' ? { ...step, title: 'Scan successful', completed: true, info: '' } : step
@@ -132,10 +139,11 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
dispatch(removeGitOperationProgress(processUid));
scanInProgress();
const foundCollectionPaths = await dispatch(scanForBrunoFiles(targetPath));
const scanResult = await dispatch(scanForBrunoFiles(targetPath));
scanFinished();
setCollectionPaths(foundCollectionPaths);
setCollectionPaths(scanResult?.items || []);
setSkippedCollectionPaths(scanResult?.skippedItems || []);
} catch (err) {
cloneError();
dispatch(removeGitOperationProgress(processUid));
@@ -157,22 +165,20 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
});
};
const handleCollectionSelect = (collection) => {
const handleCollectionSelect = (collectionPathname) => {
setSelectedCollectionPaths((prevSelected) =>
prevSelected.includes(collection)
? prevSelected.filter((c) => c !== collection)
: [...prevSelected, collection]
prevSelected.includes(collectionPathname)
? prevSelected.filter((pathname) => pathname !== collectionPathname)
: [...prevSelected, collectionPathname]
);
};
const handleSelectAllCollections = (e) => {
setSelectedCollectionPaths(e.target.checked ? [...collectionPaths] : []);
};
const getRelativePath = (fullPath, pathname) => {
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
return path.join(dir, name);
const handleSelectAllCollections = (e, filteredCollectionPaths) => {
setSelectedCollectionPaths((prevSelected) => (
e.target.checked
? Array.from(new Set([...prevSelected, ...filteredCollectionPaths]))
: prevSelected.filter((pathname) => !filteredCollectionPaths.includes(pathname))
));
};
const isScanCompleted = () => steps.some((step) => step.step === 'scan' && step.completed);
@@ -183,6 +189,36 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
const isError = () => steps.some((step) => step.error);
const handleBackButtonClick = () => {
setView('form');
setSteps([]);
setSelectedCollectionPaths([]);
};
const renderFooterLeft = () => {
if (isError()) {
return (
<Button
type="button"
variant="ghost"
color="secondary"
onClick={handleBackButtonClick}
data-testid="clone-git-repository-modal-back-btn"
>
Back
</Button>
);
}
if (isScanCompleted() && collectionPaths?.length > 0) {
return (
<SelectionFooter>
<span>{selectedCollectionPaths.length}</span> of {collectionPaths.length} selected
</SelectionFooter>
);
}
return null;
};
const handleConfirm = () => {
const buttonText = getConfirmText();
switch (buttonText) {
@@ -211,12 +247,6 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
? 'Close'
: 'Open';
const handleBackButtonClick = () => {
setView('form');
setSteps([]);
setSelectedCollectionPaths([]);
};
if (!gitVersion) {
return <GitNotFoundModal onClose={onClose} />;
}
@@ -232,8 +262,7 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
confirmDisabled={isConfirmDisabled()}
hideFooter={isFooterHidden()}
hideCancel={isError() || (isScanCompleted() && !collectionPaths?.length)}
showBackButton={isError()}
handleBack={handleBackButtonClick}
footerLeft={renderFooterLeft()}
>
<StyledWrapper>
{view === 'form' && (
@@ -305,22 +334,16 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
)}
{view === 'progress' && (
<>
{steps.length > 0 && (
<div className="mt-4">
{steps.some((step) => !step.completed || step.error) && (
<div className="clone-progress-steps">
<ul>
{steps.map((step, index) => (
{steps.filter((step) => !step.completed || step.error).map((step, index) => (
<li key={index} className="flex-col items-center space-x-2 mt-1">
<div className="flex">
{step.error ? (
<IconAlertCircle className="text-red-500" size={18} strokeWidth={1.5} />
<IconAlertCircle className="clone-step-error-icon" size={18} strokeWidth={1.5} />
) : (
<>
{step.completed ? (
<IconCheck className="text-green-500" size={18} strokeWidth={1.5} />
) : (
<IconRefresh className="text-yellow-500 animate-spin" size={18} strokeWidth={1.5} />
)}
</>
<IconRefresh className="clone-step-progress-icon animate-spin" size={18} strokeWidth={1.5} />
)}
<span className="ml-2">{step.title}</span>
</div>
@@ -335,23 +358,28 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
</div>
)}
{isScanCompleted() && (
<div className="mt-4 mb-4">
<div className="w-full min-w-0 flex flex-col gap-3">
<SkippedPathsWarning paths={skippedCollectionPaths} itemNoun="collections" />
{collectionPaths.length === 0 && (
<div className="flex">
<IconAlertCircle className="text-yellow-500" size={18} strokeWidth={1.5} />
<h3 className="text-sm ml-2">No bruno collections found in this repository.</h3>
<div className="scan-warning flex items-start gap-2">
<IconAlertCircle className="scan-warning-icon" size={18} strokeWidth={1.5} />
<div>No Bruno collections were found in this repository.</div>
</div>
)}
{collectionPaths.length > 0 && (
<SelectionList
title={`Collections (${collectionPaths.length})`}
title="Collections"
searchPlaceholder="Search Collections"
items={collectionPaths}
selectedItems={selectedCollectionPaths}
onSelectAll={handleSelectAllCollections}
onItemToggle={handleCollectionSelect}
getItemId={(collection) => collection}
renderItemLabel={(collection) => getRelativePath(formik.values.collectionLocation, collection)}
getItemId={(collection) => collection.pathname}
renderItemTitle={(collection) => collection.name}
renderItemDescription={(collection) => getRelativePath(formik.values.collectionLocation, collection.pathname)}
visibleRows={8}
rowHeight={60}
rowGap={4}
/>
)}
</div>

View File

@@ -1,6 +1,12 @@
import styled from 'styled-components';
import { IMPORT_COLLECTION_SELECTION_WIDTH } from 'components/SelectionList/constants';
const StyledWrapper = styled.div`
width: ${IMPORT_COLLECTION_SELECTION_WIDTH};
max-width: 100%;
min-width: 0;
box-sizing: border-box;
.tabs {
.tab {
padding: 6px 0px;

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import FileTab from './FileTab';
@@ -37,86 +38,88 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
}
return (
<Modal size="md" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<StyledWrapper className="flex flex-col h-full w-[600px] max-w-[600px]">
<div className="flex w-full mb-6">
<div className="flex justify-start w-full tabs">
<div
className={getTabClassname(IMPORT_TABS.FILE)}
onClick={handleTabSelect(IMPORT_TABS.FILE)}
data-testid="file-tab"
>
<IconFileImport size={18} strokeWidth={1.5} className="mr-2" />
File
</div>
<div
className={getTabClassname(IMPORT_TABS.GITHUB)}
onClick={handleTabSelect(IMPORT_TABS.GITHUB)}
data-testid="github-tab"
>
<IconBrandGit size={18} strokeWidth={1.5} className="mr-2" />
Git Repository
</div>
<div
className={getTabClassname(IMPORT_TABS.URL)}
onClick={handleTabSelect(IMPORT_TABS.URL)}
data-testid="url-tab"
>
<IconUnlink size={18} strokeWidth={1.5} className="mr-2" />
URL
</div>
</div>
</div>
{errorMessage && (
<div
data-testid="import-error-message"
className="mb-4 p-2 border rounded-md"
style={{
backgroundColor: theme.status.danger.background,
borderColor: theme.status.danger.border
}}
>
<div className="flex gap-2">
<Portal>
<Modal size="md" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<StyledWrapper className="flex flex-col h-full">
<div className="flex w-full mb-6">
<div className="flex justify-start w-full tabs">
<div
className="text-xs flex-1"
style={{ color: theme.status.danger.text }}
className={getTabClassname(IMPORT_TABS.FILE)}
onClick={handleTabSelect(IMPORT_TABS.FILE)}
data-testid="file-tab"
>
{errorMessage}
<IconFileImport size={18} strokeWidth={1.5} className="mr-2" />
File
</div>
<div
className="close-button flex items-center cursor-pointer"
onClick={() => setErrorMessage('')}
style={{ color: theme.status.danger.text }}
className={getTabClassname(IMPORT_TABS.GITHUB)}
onClick={handleTabSelect(IMPORT_TABS.GITHUB)}
data-testid="github-tab"
>
<IconX size={16} strokeWidth={1.5} />
<IconBrandGit size={18} strokeWidth={1.5} className="mr-2" />
Git Repository
</div>
<div
className={getTabClassname(IMPORT_TABS.URL)}
onClick={handleTabSelect(IMPORT_TABS.URL)}
data-testid="url-tab"
>
<IconUnlink size={18} strokeWidth={1.5} className="mr-2" />
URL
</div>
</div>
</div>
)}
{tab === IMPORT_TABS.FILE && (
<FileTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.GITHUB && (
<GitHubTab
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.URL && (
<UrlTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
</StyledWrapper>
</Modal>
{errorMessage && (
<div
data-testid="import-error-message"
className="mb-4 p-2 border rounded-md"
style={{
backgroundColor: theme.status.danger.background,
borderColor: theme.status.danger.border
}}
>
<div className="flex gap-2">
<div
className="text-xs flex-1"
style={{ color: theme.status.danger.text }}
>
{errorMessage}
</div>
<div
className="close-button flex items-center cursor-pointer"
onClick={() => setErrorMessage('')}
style={{ color: theme.status.danger.text }}
>
<IconX size={16} strokeWidth={1.5} />
</div>
</div>
</div>
)}
{tab === IMPORT_TABS.FILE && (
<FileTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.GITHUB && (
<GitHubTab
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.URL && (
<UrlTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
</StyledWrapper>
</Modal>
</Portal>
);
};

View File

@@ -0,0 +1,62 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.status.warning.text};
background-color: ${(props) => props.theme.status.warning.background};
border: 1px solid ${(props) => props.theme.status.warning.border};
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.375rem 0.5rem;
font-size: ${(props) => props.theme.font.size.sm};
.scan-warning-icon {
color: ${(props) => props.theme.status.warning.text};
flex-shrink: 0;
}
.scan-warning-action {
background: transparent;
border: 0;
padding: 0;
color: inherit;
font-weight: 600;
text-decoration: underline;
cursor: pointer;
flex-shrink: 0;
}
.scan-warning-list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
max-height: 8rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.scan-warning-list li {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.25rem 0;
border-top: 1px solid ${(props) => props.theme.status.warning.border};
}
.scan-warning-list li:first-child {
border-top: 0;
}
.scan-warning-path {
font-family: ${(props) => props.theme.font.codeFont};
font-size: ${(props) => props.theme.font.size.xs};
word-break: break-all;
}
.scan-warning-reason {
font-size: ${(props) => props.theme.font.size.xs};
opacity: 0.85;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const SkippedPathsWarning = ({ paths, itemNoun }) => {
const [showDetails, setShowDetails] = useState(false);
if (!paths || paths.length === 0) {
return null;
}
return (
<StyledWrapper>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2">
<IconAlertTriangle size={16} strokeWidth={1.5} className="scan-warning-icon" />
{paths.length} {itemNoun} were skipped because their config could not be read.
</span>
<button
type="button"
className="scan-warning-action"
onClick={() => setShowDetails((value) => !value)}
>
{showDetails ? 'Hide Details' : 'View Details'}
</button>
</div>
{showDetails && (
<ul className="scan-warning-list scrollbar-hover">
{paths.map((pathname) => (
<li key={pathname}>
<span className="scan-warning-path">{pathname}</span>
</li>
))}
</ul>
)}
</StyledWrapper>
);
};
export default SkippedPathsWarning;

View File

@@ -224,6 +224,7 @@ const openCollectionsByPathname = async (win, watcher, collectionPaths, options
};
module.exports = {
getCollectionConfigFile,
openCollection,
openCollectionDialog,
openCollectionsByPathname,

View File

@@ -57,7 +57,7 @@ const {
isCollectionRootBruFile,
scanForBrunoFiles
} = require('../utils/filesystem');
const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { getCollectionConfigFile, openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids');
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
@@ -2459,9 +2459,30 @@ const registerMainEventHandlers = (mainWindow, watcher) => {
app.addRecentDocument(pathname);
});
ipcMain.handle('renderer:scan-for-bruno-files', (event, dir) => {
ipcMain.handle('renderer:scan-for-bruno-files', async (event, dir) => {
try {
return scanForBrunoFiles(dir);
const collectionPaths = await scanForBrunoFiles(dir);
const scanResults = await Promise.all(
collectionPaths.map(async (pathname) => {
try {
const brunoConfig = await getCollectionConfigFile(pathname);
return {
pathname,
name: brunoConfig.name
};
} catch (error) {
console.warn(`Skipping invalid Bruno collection at ${pathname}: ${error.message}`);
return { pathname, skipped: true };
}
})
);
return {
items: scanResults.filter((result) => !result.skipped),
skippedItems: scanResults.filter((result) => result.skipped).map(({ pathname }) => pathname)
};
} catch (error) {
throw new Error(error.message);
}

View File

@@ -490,7 +490,7 @@ const scanForBrunoFiles = async (dir) => {
return;
}
scanDir(fullPath);
} else if (file === 'bruno.json') {
} else if ((file === 'bruno.json' || file === 'opencollection.yml') && !brunoFolders.includes(currentDir)) {
brunoFolders.push(currentDir);
}
});

View File

@@ -32,7 +32,9 @@ test.describe('Multiple Files Upload', () => {
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
// Check that the Collections count shows 2 collections in the Bulk Import modal
await expect(bulkImportModal.getByText('Collections (2)')).toBeVisible();
const collectionsHeading = bulkImportModal.getByTestId('selection-heading').filter({ hasText: 'Collections' });
await expect(collectionsHeading).toBeVisible();
await expect(collectionsHeading.getByTestId('selection-count')).toHaveText('2');
// Verify collection names are displayed
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();

View File

@@ -34,7 +34,9 @@ test.describe('All Collection Types Bulk Import', () => {
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
// Check that the Collections count shows 4 collections in the Bulk Import modal
await expect(bulkImportModal.getByText('Collections (4)')).toBeVisible();
const collectionsHeading = bulkImportModal.getByTestId('selection-heading').filter({ hasText: 'Collections' });
await expect(collectionsHeading).toBeVisible();
await expect(collectionsHeading.getByTestId('selection-count')).toHaveText('4');
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Bruno Collection')).toBeVisible();

View File

@@ -16,14 +16,13 @@ const getFullyVisibleRowNames = async (list: Locator) => {
const rect = item.getBoundingClientRect();
return rect.top >= listRect.top && rect.bottom <= listRect.bottom;
})
.map((item) => item.textContent?.trim())
.map((item) => item.querySelector('.selection-item-title')?.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);
@@ -61,16 +60,18 @@ test.describe('Bulk Import Selection List', () => {
const bulkImportModal = page.getByRole('dialog');
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
await expect(bulkImportModal.getByText('Collections (10)')).toBeVisible();
const collectionsHeading = bulkImportModal.getByTestId('selection-heading').filter({ hasText: 'Collections' });
await expect(collectionsHeading).toBeVisible();
await expect(collectionsHeading.getByTestId('selection-count')).toHaveText('10');
const collectionList = bulkImportModal.locator('.selection-list').first();
const collectionList = collectionsHeading.locator('..').getByTestId('selection-list');
await expect(collectionList).toBeVisible();
const initialVisibleRows = await getFullyVisibleRowNames(collectionList);
expect(initialVisibleRows).toHaveLength(expectedVisibleRows);
expect(initialVisibleRows.length).toBeGreaterThan(0);
expect(initialVisibleRows.length).toBeLessThan(10);
expect(initialVisibleRows[0]).toBe(getViewportCollectionName(1));
expect(initialVisibleRows[expectedVisibleRows - 1]).toBe(getViewportCollectionName(expectedVisibleRows));
expect(initialVisibleRows).not.toContain(getViewportCollectionName(expectedVisibleRows + 1));
expect(initialVisibleRows).not.toContain(getViewportCollectionName(10));
await collectionList.evaluate((list) => {
list.scrollTop = list.scrollHeight;
@@ -78,7 +79,7 @@ test.describe('Bulk Import Selection List', () => {
await expect(async () => {
const scrolledVisibleRows = await getFullyVisibleRowNames(collectionList);
expect(scrolledVisibleRows).toHaveLength(expectedVisibleRows);
expect(scrolledVisibleRows.length).toBeGreaterThan(0);
expect(scrolledVisibleRows).toContain(getViewportCollectionName(9));
expect(scrolledVisibleRows).toContain(getViewportCollectionName(10));
}).toPass({ timeout: 5000 });

View File

@@ -0,0 +1,137 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import * as fs from 'fs/promises';
import { closeAllCollections } from '../../utils/page';
const getCollectionName = (index: number) => `Select All Collection ${String(index).padStart(2, '0')}`;
test.describe('Bulk Import - Select all', () => {
const testDataDir = path.join(__dirname, '../test-data');
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('Select all toggles every collection on, then off, and reflects indeterminate state', async ({
page,
createTmpDir
}) => {
const sourceFile = path.join(testDataDir, 'sample-postman.json');
const tempDir = await createTmpDir('bulk-import-select-all');
const sourceContent = JSON.parse(await fs.readFile(sourceFile, 'utf-8'));
const importFiles: string[] = [];
const totalCollections = 6;
for (let index = 1; index <= totalCollections; index++) {
const filePath = path.join(tempDir, `sample-postman-${index}.json`);
const fileContent = {
...sourceContent,
info: {
...sourceContent.info,
name: getCollectionName(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');
const collectionsSection = bulkImportModal.getByTestId('selection-section-collections');
await expect(collectionsSection.getByTestId('selection-count')).toHaveText(String(totalCollections));
const collectionList = collectionsSection.getByTestId('selection-list');
const itemCheckboxes = collectionList.locator('.selection-item input[type="checkbox"]');
const selectAllToggle = collectionsSection.getByTestId('selection-select-all-toggle');
const selectAllCheckbox = selectAllToggle.locator('input[type="checkbox"]');
await expect(itemCheckboxes).toHaveCount(totalCollections);
await test.step('Bulk import opens with every collection pre-selected', async () => {
await expect(selectAllCheckbox).toBeChecked();
for (let i = 0; i < totalCollections; i++) {
await expect(itemCheckboxes.nth(i)).toBeChecked();
}
});
await test.step('Clicking Select all unchecks every collection', async () => {
await selectAllToggle.click();
await expect(selectAllCheckbox).not.toBeChecked();
for (let i = 0; i < totalCollections; i++) {
await expect(itemCheckboxes.nth(i)).not.toBeChecked();
}
});
await test.step('Clicking Select all again rechecks every collection', async () => {
await selectAllToggle.click();
await expect(selectAllCheckbox).toBeChecked();
for (let i = 0; i < totalCollections; i++) {
await expect(itemCheckboxes.nth(i)).toBeChecked();
}
});
await test.step('Unchecking a single collection puts Select all into the indeterminate state', async () => {
await collectionList.locator('.selection-item').first().click();
const checkedCount = await itemCheckboxes.evaluateAll(
(nodes) => nodes.filter((node) => (node as HTMLInputElement).checked).length
);
expect(checkedCount).toBe(totalCollections - 1);
const isIndeterminate = await selectAllCheckbox.evaluate(
(node) => (node as HTMLInputElement).indeterminate
);
expect(isIndeterminate).toBe(true);
});
await test.step('Clicking Select all from indeterminate selects every collection', async () => {
await selectAllToggle.click();
await expect(selectAllCheckbox).toBeChecked();
const isIndeterminate = await selectAllCheckbox.evaluate(
(node) => (node as HTMLInputElement).indeterminate
);
expect(isIndeterminate).toBe(false);
for (let i = 0; i < totalCollections; i++) {
await expect(itemCheckboxes.nth(i)).toBeChecked();
}
});
await test.step('Search narrows Select all to the filtered subset only', async () => {
await selectAllToggle.click();
await expect(selectAllCheckbox).not.toBeChecked();
const searchInput = collectionsSection.getByTestId('selection-search-input');
await searchInput.fill('01');
const visibleCount = await itemCheckboxes.count();
expect(visibleCount).toBeGreaterThan(0);
expect(visibleCount).toBeLessThan(totalCollections);
await selectAllToggle.click();
await expect(selectAllCheckbox).toBeChecked();
for (let i = 0; i < visibleCount; i++) {
await expect(itemCheckboxes.nth(i)).toBeChecked();
}
await searchInput.fill('');
await expect(itemCheckboxes).toHaveCount(totalCollections);
const isIndeterminate = await selectAllCheckbox.evaluate(
(node) => (node as HTMLInputElement).indeterminate
);
expect(isIndeterminate).toBe(true);
});
await page.getByTestId('modal-close-button').click();
await expect(page.locator('.bruno-modal-backdrop')).toHaveCount(0);
});
});