mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: import modal revamp (#8121)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
14
packages/bruno-app/src/components/SelectionFooter/index.js
Normal file
14
packages/bruno-app/src/components/SelectionFooter/index.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SELECTION_LIST_MAX_WIDTH = '720px';
|
||||
export const IMPORT_COLLECTION_SELECTION_WIDTH = '600px';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -224,6 +224,7 @@ const openCollectionsByPathname = async (win, watcher, collectionPaths, options
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getCollectionConfigFile,
|
||||
openCollection,
|
||||
openCollectionDialog,
|
||||
openCollectionsByPathname,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
137
tests/import/bulk-import/004-select-all.spec.ts
Normal file
137
tests/import/bulk-import/004-select-all.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user