From b43a5e6e0a9ac3cc4b3ffd904a034d47240f22bf Mon Sep 17 00:00:00 2001 From: prateek-bruno Date: Thu, 28 May 2026 15:58:22 +0530 Subject: [PATCH] feat: import modal revamp (#8121) --- .../bruno-app/src/components/Modal/index.js | 42 ++-- .../src/components/SelectionFooter/index.js | 14 ++ .../components/SelectionList/StyledWrapper.js | 200 +++++++++++++++--- .../src/components/SelectionList/constants.js | 2 + .../src/components/SelectionList/index.js | 166 +++++++++++---- .../BulkImportCollectionLocation/index.js | 43 ++-- .../CloneGitRespository/StyledWrapper.js | 31 +++ .../Sidebar/CloneGitRespository/index.js | 112 ++++++---- .../Sidebar/ImportCollection/StyledWrapper.js | 6 + .../Sidebar/ImportCollection/index.js | 145 ++++++------- .../SkippedPathsWarning/StyledWrapper.js | 62 ++++++ .../components/SkippedPathsWarning/index.js | 40 ++++ .../bruno-electron/src/app/collections.js | 1 + packages/bruno-electron/src/ipc/collection.js | 27 ++- .../bruno-electron/src/utils/filesystem.js | 2 +- .../001-multiple-files-upload.spec.ts | 4 +- .../002-all-collection-types.spec.ts | 4 +- .../003-selection-list-viewport.spec.ts | 17 +- .../import/bulk-import/004-select-all.spec.ts | 137 ++++++++++++ 19 files changed, 822 insertions(+), 233 deletions(-) create mode 100644 packages/bruno-app/src/components/SelectionFooter/index.js create mode 100644 packages/bruno-app/src/components/SelectionList/constants.js create mode 100644 packages/bruno-app/src/components/SkippedPathsWarning/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/SkippedPathsWarning/index.js create mode 100644 tests/import/bulk-import/004-select-all.spec.ts diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js index 75662d2bc..9ddab746b 100644 --- a/packages/bruno-app/src/components/Modal/index.js +++ b/packages/bruno-app/src/components/Modal/index.js @@ -28,6 +28,7 @@ const ModalFooter = ({ confirmDisabled, hideCancel, hideFooter, + footerLeft, confirmButtonColor = 'primary', dataTestId = 'modal' }) => { @@ -39,24 +40,27 @@ const ModalFooter = ({ } return ( -
- - - - - - +
+
{footerLeft}
+
+ + + + + + +
); }; @@ -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} /> diff --git a/packages/bruno-app/src/components/SelectionFooter/index.js b/packages/bruno-app/src/components/SelectionFooter/index.js new file mode 100644 index 000000000..7e6db28b5 --- /dev/null +++ b/packages/bruno-app/src/components/SelectionFooter/index.js @@ -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; diff --git a/packages/bruno-app/src/components/SelectionList/StyledWrapper.js b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js index c67b7a7d7..b44491ef4 100644 --- a/packages/bruno-app/src/components/SelectionList/StyledWrapper.js +++ b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/SelectionList/constants.js b/packages/bruno-app/src/components/SelectionList/constants.js new file mode 100644 index 000000000..bb4c1b2dd --- /dev/null +++ b/packages/bruno-app/src/components/SelectionList/constants.js @@ -0,0 +1,2 @@ +export const SELECTION_LIST_MAX_WIDTH = '720px'; +export const IMPORT_COLLECTION_SELECTION_WIDTH = '600px'; diff --git a/packages/bruno-app/src/components/SelectionList/index.js b/packages/bruno-app/src/components/SelectionList/index.js index 2913e4803..0366584fc 100644 --- a/packages/bruno-app/src/components/SelectionList/index.js +++ b/packages/bruno-app/src/components/SelectionList/index.js @@ -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 ( + <> + {itemTitle} + {description && ( + + {typeof description === 'string' ? normalizePath(description) : description} + + )} + + ); + }; + return ( -
+
{title} - + {items.length}
-
    - {items.length === 0 && ( -
  • {emptyMessage}
  • - )} - {items.map((item) => { - const itemId = getItemId(item); - const isSelected = selectedItems.includes(itemId); - - return ( -
  • - +
    +
    + + +
    +
      + {items.length === 0 && ( +
    • + {emptyMessage}
    • - ); - })} -
    + )} + {showFilteredEmptyState && ( +
  • + + {`No matching ${typeof title === 'string' ? title.toLowerCase() : 'items'} found`} + +
  • + )} + {filteredEntries.map(({ item, itemId }) => { + const isSelected = selectedItems.includes(itemId); + + return ( +
  • + +
  • + ); + })} +
+
+ {showSelectedCount && ( + + {selectedItems.length} of {items.length} selected + + )}
); }; diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js index e9829272f..8e3b1c661 100644 --- a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js @@ -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 = ({ ) : ( <> -
+
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} />
{importType === 'bulk' && ( <> -
+
env.uid} - renderItemLabel={(env) => env.name} - visibleRows={5} + renderItemTitle={(env) => env.name} + visibleRows={4} + rowHeight={30} + rowGap={4} emptyMessage="No environments found" + showSelectedCount={true} />
diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js index 9a589967d..f65efce4f 100644 --- a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js index 151bc02d9..b48a5e06a 100644 --- a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js +++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js @@ -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 ( + + ); + } + if (isScanCompleted() && collectionPaths?.length > 0) { + return ( + + {selectedCollectionPaths.length} of {collectionPaths.length} selected + + ); + } + 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 ; } @@ -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()} > {view === 'form' && ( @@ -305,22 +334,16 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null )} {view === 'progress' && ( <> - {steps.length > 0 && ( -
+ {steps.some((step) => !step.completed || step.error) && ( +
    - {steps.map((step, index) => ( + {steps.filter((step) => !step.completed || step.error).map((step, index) => (
  • {step.error ? ( - + ) : ( - <> - {step.completed ? ( - - ) : ( - - )} - + )} {step.title}
    @@ -335,23 +358,28 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
)} {isScanCompleted() && ( -
+
+ {collectionPaths.length === 0 && ( -
- -

No bruno collections found in this repository.

+
+ +
No Bruno collections were found in this repository.
)} {collectionPaths.length > 0 && ( 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} /> )}
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js index 5e1e3be3d..78baa8968 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js index 6c39274d7..96120be21 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js @@ -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 ( - - -
-
-
- - File -
-
- - Git Repository -
-
- - URL -
-
-
- - {errorMessage && ( -
-
+ + + +
+
- {errorMessage} + + File
setErrorMessage('')} - style={{ color: theme.status.danger.text }} + className={getTabClassname(IMPORT_TABS.GITHUB)} + onClick={handleTabSelect(IMPORT_TABS.GITHUB)} + data-testid="github-tab" > - + + Git Repository +
+
+ + URL
- )} - {tab === IMPORT_TABS.FILE && ( - - )} - {tab === IMPORT_TABS.GITHUB && ( - - )} - {tab === IMPORT_TABS.URL && ( - - )} -
-
+ {errorMessage && ( +
+
+
+ {errorMessage} +
+
setErrorMessage('')} + style={{ color: theme.status.danger.text }} + > + +
+
+
+ )} + + {tab === IMPORT_TABS.FILE && ( + + )} + {tab === IMPORT_TABS.GITHUB && ( + + )} + {tab === IMPORT_TABS.URL && ( + + )} + + +
); }; diff --git a/packages/bruno-app/src/components/SkippedPathsWarning/StyledWrapper.js b/packages/bruno-app/src/components/SkippedPathsWarning/StyledWrapper.js new file mode 100644 index 000000000..ed3386f19 --- /dev/null +++ b/packages/bruno-app/src/components/SkippedPathsWarning/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/SkippedPathsWarning/index.js b/packages/bruno-app/src/components/SkippedPathsWarning/index.js new file mode 100644 index 000000000..77b3a6a99 --- /dev/null +++ b/packages/bruno-app/src/components/SkippedPathsWarning/index.js @@ -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 ( + +
+ + + {paths.length} {itemNoun} were skipped because their config could not be read. + + +
+ {showDetails && ( +
    + {paths.map((pathname) => ( +
  • + {pathname} +
  • + ))} +
+ )} +
+ ); +}; + +export default SkippedPathsWarning; diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index c877c77c7..f5c8a8080 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -224,6 +224,7 @@ const openCollectionsByPathname = async (win, watcher, collectionPaths, options }; module.exports = { + getCollectionConfigFile, openCollection, openCollectionDialog, openCollectionsByPathname, diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 09dd7af62..e81662fb4 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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); } diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index 9a56525ae..8df0a261f 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -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); } }); diff --git a/tests/import/bulk-import/001-multiple-files-upload.spec.ts b/tests/import/bulk-import/001-multiple-files-upload.spec.ts index 8b2bbf4d0..e60088797 100644 --- a/tests/import/bulk-import/001-multiple-files-upload.spec.ts +++ b/tests/import/bulk-import/001-multiple-files-upload.spec.ts @@ -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(); diff --git a/tests/import/bulk-import/002-all-collection-types.spec.ts b/tests/import/bulk-import/002-all-collection-types.spec.ts index 285dad9e4..98da740d6 100644 --- a/tests/import/bulk-import/002-all-collection-types.spec.ts +++ b/tests/import/bulk-import/002-all-collection-types.spec.ts @@ -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(); diff --git a/tests/import/bulk-import/003-selection-list-viewport.spec.ts b/tests/import/bulk-import/003-selection-list-viewport.spec.ts index 4dbe50841..8cc29dad1 100644 --- a/tests/import/bulk-import/003-selection-list-viewport.spec.ts +++ b/tests/import/bulk-import/003-selection-list-viewport.spec.ts @@ -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 }); diff --git a/tests/import/bulk-import/004-select-all.spec.ts b/tests/import/bulk-import/004-select-all.spec.ts new file mode 100644 index 000000000..7b0c2b789 --- /dev/null +++ b/tests/import/bulk-import/004-select-all.spec.ts @@ -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); + }); +});