From 39b655b05d77c0a5e0eb86e882dccd45b730b032 Mon Sep 17 00:00:00 2001 From: Gopi-bruno Date: Fri, 21 Aug 2026 01:50:34 +0530 Subject: [PATCH 01/16] feat: handle environment name conflicts during import with replace or copy options --- .../ImportEnvironmentModal/StyledWrapper.js | 288 +++++++++++ .../Common/ImportEnvironmentModal/index.js | 482 +++++++++++++++--- .../src/ui/CountBadge/StyledWrapper.js | 79 +++ packages/bruno-app/src/ui/CountBadge/index.js | 26 + packages/bruno-app/src/ui/index.js | 1 + .../src/utils/importers/bruno-environment.js | 51 +- .../src/utils/importers/file-reader.js | 2 +- .../utils/importers/postman-environment.js | 9 +- .../collection-env-import.spec.ts | 2 + .../global-env-import.spec.ts | 2 + .../collection-env-import.spec.ts | 1 + .../env-color-import/env-color-import.spec.ts | 1 + .../global-env-import.spec.ts | 1 + .../fixtures/development-env.json | 6 + .../fixtures/duplicate-names-in-batch.json | 7 + .../name-conflicts/fixtures/invalid-env.json | 4 + .../fixtures/production-env-updated.json | 7 + .../fixtures/production-env.json | 8 + .../fixtures/staging-env-updated.json | 6 + .../name-conflicts/fixtures/staging-env.json | 6 + .../name-conflicts/name-conflicts.spec.ts | 257 ++++++++++ tests/utils/page/actions.ts | 2 + tests/utils/page/environments/index.ts | 26 +- 23 files changed, 1174 insertions(+), 100 deletions(-) create mode 100644 packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js create mode 100644 packages/bruno-app/src/ui/CountBadge/StyledWrapper.js create mode 100644 packages/bruno-app/src/ui/CountBadge/index.js create mode 100644 tests/environments/import-environment/name-conflicts/fixtures/development-env.json create mode 100644 tests/environments/import-environment/name-conflicts/fixtures/duplicate-names-in-batch.json create mode 100644 tests/environments/import-environment/name-conflicts/fixtures/invalid-env.json create mode 100644 tests/environments/import-environment/name-conflicts/fixtures/production-env-updated.json create mode 100644 tests/environments/import-environment/name-conflicts/fixtures/production-env.json create mode 100644 tests/environments/import-environment/name-conflicts/fixtures/staging-env-updated.json create mode 100644 tests/environments/import-environment/name-conflicts/fixtures/staging-env.json create mode 100644 tests/environments/import-environment/name-conflicts/name-conflicts.spec.ts diff --git a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js new file mode 100644 index 00000000000..b26f819c9d1 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js @@ -0,0 +1,288 @@ +import styled from 'styled-components'; + +export const StyledWrapper = styled.div` + /* Upload Step */ + .upload-container { + padding: 0.5rem 0; + } + + .upload-dropzone { + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + width: 100%; + border-radius: ${(props) => props.theme.border.radius.lg || '0.5rem'}; + border: 2px dashed ${(props) => props.theme.border.border0}; + padding: 3rem; + text-align: center; + cursor: pointer; + transition: all 0.2s ease-in-out; + background-color: transparent; + + &:hover { + border-color: ${(props) => props.theme.colors.text.subtext0}; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${(props) => props.theme.brand}, 0 0 0 4px transparent; + } + + &.is-drag-over { + border-color: ${(props) => props.theme.colors.text.yellow}; + background-color: ${(props) => props.theme.colors.bg.yellow}; + } + } + + .upload-dropzone-icon { + color: ${(props) => props.theme.colors.text.base}; + } + + .upload-dropzone-title { + margin-top: 0.5rem; + display: block; + font-weight: 500; + } + + .upload-dropzone-subtitle { + margin-top: 0.25rem; + display: block; + font-size: 0.75rem; + color: ${(props) => props.theme.colors.text.subtext0}; + } + + /* Review Step */ + .modal-content { + display: flex; + flex-direction: column; + height: 100%; + max-height: 60vh; + overflow: hidden; + } + + .modal-header { + font-weight: 600; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + } + + .scroll-area { + flex: 1; + overflow-y: auto; + padding-right: 0.25rem; + } + + .environments-list-container { + border: 1px solid ${(props) => props.theme.border.border0}; + border-radius: ${(props) => props.theme.border.radius.base}; + display: flex; + flex-direction: column; + background-color: ${(props) => props.theme.background.base}; + overflow: hidden; + } + + .warning-block { + background-color: ${(props) => props.theme.colors.bg.yellow}; + border-bottom: 1px solid ${(props) => props.theme.border.border0}; + color: ${(props) => props.theme.colors.text.base}; + padding: 0.75rem; + font-size: 0.875rem; + flex-shrink: 0; + } + + .warning-header { + font-size: ${(props) => props.theme.font.size.base}; + line-height: 1; + letter-spacing: normal; + display: flex; + align-items: center; + color: ${(props) => props.theme.colors.text.yellow}; + margin-bottom: 0.5rem; + } + + .warning-title { + font-weight: 700; + } + + .search-block { + display: flex; + align-items: center; + padding: 0.75rem; + flex-shrink: 0; + gap: 1rem; + border-bottom: 1px solid ${(props) => props.theme.border.border0}; + background-color: ${(props) => props.theme.background.subtle}; + } + + .search-input-wrapper { + flex: 1; + } + + .select-all-wrapper { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + } + + .select-all-checkbox { + margin-right: 0.5rem; + cursor: pointer; + } + + .select-all-text { + font-size: 0.875rem; + font-weight: 500; + } + + .group-container { + &.has-border-bottom { + border-bottom: 1px solid ${(props) => props.theme.border.border0}; + } + } + + .group-header { + display: flex; + align-items: center; + padding: 0.5rem; + background: ${(props) => props.theme.background.mantle}; + } + + .group-title-wrapper { + display: flex; + align-items: center; + cursor: pointer; + flex: 1; + user-select: none; + } + + .group-title { + font-weight: 600; + margin-left: 0.5rem; + } + + .group-list { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .env-item { + display: flex; + align-items: flex-start; + padding-left: 1.5rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .env-item-label { + display: flex; + flex: 1; + align-items: flex-start; + cursor: pointer; + min-width: 0; + } + + .env-item-checkbox { + margin-right: 0.75rem; + margin-top: 0.25rem; + cursor: pointer; + } + + .env-item-content { + flex: 1; + min-width: 0; + } + + .env-name { + color: ${(props) => props.theme.colors.text.base}; + font-family: Inter; + font-weight: 500; + font-size: ${(props) => props.theme.font.size.base}; + line-height: 1.5; + } + + .env-filepath { + font-weight: 500; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.subtext1}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.125rem; + } + + .env-actions { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: 0.5rem; + flex-shrink: 0; + } + + .empty-state { + padding-left: 1.5rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + color: ${(props) => props.theme.colors.text.subtext0}; + } + + .footer-left-content { + font-size: 13px; + line-height: 20px; + letter-spacing: normal; + font-weight: 500; + color: ${(props) => props.theme.colors.text.subtext0}; + } +`; + +export const DropdownTrigger = styled.div` + display: flex; + align-items: center; + font-size: ${(props) => props.theme.font.size.sm}; + background-color: ${(props) => props.theme.dropdown.bg}; + border: 1px solid ${(props) => props.theme.dropdown.separator}; + border-radius: ${(props) => props.theme.border.radius.base}; + padding: 0.25rem 0.5rem; + margin-left: 0.5rem; + cursor: pointer; + color: ${(props) => props.theme.dropdown.color}; + + .icon-chevron { + color: ${(props) => props.theme.dropdown.iconColor}; + margin-left: 0.25rem; + } + + &:hover { + background-color: ${(props) => props.theme.dropdown.hoverBg}; + } + + &:focus { + outline: none; + } +`; + +export const ResolutionButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + border: 1.24px solid transparent; + + ${(props) => + props.$selected + ? ` + background: ${props.theme.background.base}; + border-color: ${props.theme.border.border0}; + color: ${props.theme.brand}; + ` + : ` + background: transparent; + color: ${props.theme.colors.text.subtext0}; + `} +`; diff --git a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js index a1ff3f1d6d8..e927d103cad 100644 --- a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js +++ b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js @@ -1,23 +1,54 @@ import React, { useState } from 'react'; +import CountBadge from 'ui/CountBadge'; +import MenuDropdown from 'ui/MenuDropdown'; +import { DropdownTrigger, ResolutionButton, StyledWrapper } from './StyledWrapper'; import Portal from 'components/Portal'; import Modal from 'components/Modal'; +import SearchInput from 'components/SearchInput'; +import IconAlertTriangleFilled from 'components/Icons/IconAlertTriangleFilled'; import toast from 'react-hot-toast'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import importPostmanEnvironment from 'utils/importers/postman-environment'; import importBrunoEnvironment from 'utils/importers/bruno-environment'; import { readMultipleFiles } from 'utils/importers/file-reader'; -import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions'; -import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import { importEnvironment, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { addGlobalEnvironment, saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { toastError } from 'utils/common/error'; -import { IconFileImport } from '@tabler/icons'; +import { + IconFileImport, + IconChevronDown, + IconChevronRight, + IconCopy, + IconArrowsExchange +} from '@tabler/icons'; +import { useTheme } from 'styled-components'; +import { pluralizeWord } from 'utils/common/index'; +const generateCopyName = (baseName, existingNames) => { + let counter = 1; + let newName = `${baseName} copy`; + while (existingNames.includes(newName)) { + counter++; + newName = `${baseName} copy ${counter}`; + } + return newName; +}; const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEnvironmentCreated }) => { const dispatch = useDispatch(); + const theme = useTheme(); const [isDragOver, setIsDragOver] = useState(false); + const [step, setStep] = useState('UPLOAD'); // 'UPLOAD' | 'REVIEW' + const [parsedData, setParsedData] = useState({ new: [], duplicates: [], invalid: [] }); + // Set of indices from the valid items array (which is new + duplicates) + const [selectedIndices, setSelectedIndices] = useState(new Set()); + // Mapping of duplicate name -> 'copy' | 'replace' + const [resolutions, setResolutions] = useState({}); + const [searchText, setSearchText] = useState(''); + const [expandedGroups, setExpandedGroups] = useState({ invalid: true, duplicates: true, new: true }); + const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments); const isGlobal = type === 'global'; - // Validate required props if (!isGlobal && !collection) { console.error('ImportEnvironmentModal: collection prop is required when type is "collection"'); return null; @@ -26,50 +57,15 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn const modalTestId = isGlobal ? 'import-global-environment-modal' : 'import-environment-modal'; const importTestId = isGlobal ? 'import-global-environment' : 'import-environment'; - const processEnvironments = async (environments, successMessage) => { - const validEnvironments = environments.filter((env) => { - if (env.name && env.name !== 'undefined') { - return true; - } else { - toast.error('Failed to import environment: env has no name'); - return false; - } - }); - - if (validEnvironments.length === 0) { - toast.error('No valid environments found to import'); - return; - } - - try { - // Process environments sequentially to ensure unique name checking considers previously imported environments - let importedCount = 0; - for (const environment of validEnvironments) { - const action = isGlobal - ? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color }) - : importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid }); - - await dispatch(action); - importedCount++; - } - - toast.success(`${importedCount > 1 ? `${importedCount} environments` : 'Environment'} imported successfully`); - } catch (error) { - toast.error('An error occurred while importing the environment(s)'); - console.error(error); - throw error; - } - }; + const existingEnvironments = isGlobal ? globalEnvironments : (collection?.environments || []); + const existingNames = existingEnvironments.map((e) => e.name); const detectEnvironmentFormat = (data) => { - // bruno environment `single-object` export type if (data.info && data.info.type === 'bruno-environment') { return 'bruno'; } else if (Array.isArray(data)) { - // bruno environment`single-file` export type return data.some((env) => env.info && env.info.type === 'bruno-environment') ? 'bruno' : 'postman'; } else if (data.id && data.values) { - // postman environment return 'postman'; } return 'bruno'; @@ -77,29 +73,110 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn const handleImportEnvironment = async (files) => { try { - // Read and parse all files const parsedFiles = await readMultipleFiles(Array.from(files)); - - // Detect format from first file's content const format = detectEnvironmentFormat(parsedFiles[0].content); - let environments; + let result; if (format === 'postman') { - environments = await importPostmanEnvironment(parsedFiles); + result = await importPostmanEnvironment(parsedFiles); } else { - environments = await importBrunoEnvironment(parsedFiles); + result = await importBrunoEnvironment(parsedFiles); + } + + const validEnvironments = result.valid.filter((env) => env.name && env.name !== 'undefined'); + const missingNameEnvs = result.valid.filter((env) => !env.name || env.name === 'undefined').map((env) => ({ fileName: 'Unknown', error: 'Environment has no name' })); + + const allInvalid = [...result.invalid, ...missingNameEnvs]; + + if (allInvalid.length > 0) { + toast.error('One or more environment files have an invalid or unsupported format'); + return; + } + + const duplicates = validEnvironments.filter((e) => existingNames.includes(e.name)); + const newEnvs = validEnvironments.filter((e) => !existingNames.includes(e.name)); + + setParsedData({ new: newEnvs, duplicates, invalid: allInvalid }); + + // Initialize selected set + const initialSelected = new Set(); + validEnvironments.forEach((_, idx) => initialSelected.add(idx)); + setSelectedIndices(initialSelected); + + // Initialize resolutions for duplicates to 'copy' by default + const initialResolutions = {}; + duplicates.forEach((e) => { + initialResolutions[e.name] = 'copy'; + }); + setResolutions(initialResolutions); + + setStep('REVIEW'); + } catch (err) { + toastError(err, 'Import environment failed'); + } + }; + + const handleConfirmImport = async () => { + const validEnvironments = [...parsedData.new, ...parsedData.duplicates]; + const environmentsToImport = validEnvironments.filter((_, idx) => selectedIndices.has(idx)); + + if (environmentsToImport.length === 0) { + toast.error('No environments selected to import'); + return; + } + + try { + let importedCount = 0; + const currentExistingNames = [...existingNames]; + + for (const environment of environmentsToImport) { + const isDuplicate = parsedData.duplicates.includes(environment); + let action; + + if (isDuplicate) { + const resolution = resolutions[environment.name] || 'copy'; + if (resolution === 'replace') { + const existingEnv = existingEnvironments.find((e) => e.name === environment.name); + if (existingEnv) { + action = isGlobal + ? saveGlobalEnvironment({ variables: environment.variables, environmentUid: existingEnv.uid }) + : saveEnvironment(environment.variables, existingEnv.uid, collection.uid); + } + } else { + // copy + const copyName = generateCopyName(environment.name, currentExistingNames); + currentExistingNames.push(copyName); + action = isGlobal + ? addGlobalEnvironment({ name: copyName, variables: environment.variables, color: environment.color }) + : importEnvironment({ name: copyName, variables: environment.variables, color: environment.color, collectionUid: collection?.uid }); + } + } else { + const name = currentExistingNames.includes(environment.name) + ? generateCopyName(environment.name, currentExistingNames) + : environment.name; + currentExistingNames.push(name); + action = isGlobal + ? addGlobalEnvironment({ name, variables: environment.variables, color: environment.color }) + : importEnvironment({ name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid }); + } + + if (action) { + await dispatch(action); + importedCount++; + } } - await processEnvironments(environments); + toast.success(`${importedCount > 1 ? `${importedCount} environments` : 'Environment'} imported successfully`); onClose(); if (onEnvironmentCreated) { onEnvironmentCreated(); } - } catch (err) { - toastError(err, 'Import environment failed'); + } catch (error) { + toastError(error, 'An error occurred while importing the environment(s)'); } }; + // Drag and drop handlers const handleFileSelect = () => { const input = document.createElement('input'); input.type = 'file'; @@ -117,47 +194,300 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn e.preventDefault(); setIsDragOver(true); }; - const handleDragLeave = (e) => { e.preventDefault(); setIsDragOver(false); }; - const handleDrop = (e) => { e.preventDefault(); setIsDragOver(false); - const files = Array.from(e.dataTransfer.files); if (files.length > 0) { handleImportEnvironment(files); } }; + // Review handlers + + const toggleItemSelection = (env) => { + const validEnvironments = [...parsedData.new, ...parsedData.duplicates]; + const idx = validEnvironments.indexOf(env); + if (idx !== -1) { + const newSelected = new Set(selectedIndices); + if (newSelected.has(idx)) newSelected.delete(idx); + else newSelected.add(idx); + setSelectedIndices(newSelected); + } + }; + + const setGroupResolution = (res) => { + const newResolutions = { ...resolutions }; + parsedData.duplicates.forEach((env) => { + newResolutions[env.name] = res; + }); + setResolutions(newResolutions); + }; + + const setItemResolution = (envName, res) => { + setResolutions({ ...resolutions, [envName]: res }); + }; + + const toggleGroupExpanded = (group) => { + setExpandedGroups({ ...expandedGroups, [group]: !expandedGroups[group] }); + }; + + // Filtering + const filteredNew = parsedData.new.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())); + const filteredDuplicates = parsedData.duplicates.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())); + + const totalEnvironments = parsedData.new.length + parsedData.duplicates.length; + + if (step === 'UPLOAD') { + return ( + + + +
+
+ + + {isDragOver ? 'Drop your environment files here' : 'Import your environments'} + + + Drag & drop JSON files/folders or click to browse. Supports both Bruno and Postman formats. + +
+
+
+
+
+ ); + } + + const getDropdownValue = () => { + if (parsedData.duplicates.length === 0) return 'Custom'; + const allCopy = parsedData.duplicates.every((env) => resolutions[env.name] === 'copy'); + if (allCopy) return 'copy'; + const allReplace = parsedData.duplicates.every((env) => resolutions[env.name] === 'replace'); + if (allReplace) return 'replace'; + return 'Custom'; + }; + + const isAllSelected = selectedIndices.size === totalEnvironments && totalEnvironments > 0; + const toggleSelectAll = (checked) => { + if (checked) { + const allSelected = new Set(); + for (let i = 0; i < totalEnvironments; i++) { + allSelected.add(i); + } + setSelectedIndices(allSelected); + } else { + setSelectedIndices(new Set()); + } + }; + return ( - -
-
- - - {isDragOver ? 'Drop your environment files here' : 'Import your environments'} - - - Drag & drop JSON files/folders or click to browse. Supports both Bruno and Postman formats. - + + {selectedIndices.size} of {totalEnvironments} selected +
+ )} + > + +
+
+ Environments {totalEnvironments} +
+ +
+
+ {parsedData.duplicates.length > 0 && ( +
+
+ + {parsedData.duplicates.length} {pluralizeWord('environment', parsedData.duplicates.length)}  already {parsedData.duplicates.length > 1 ? 'exist' : 'exists'} with the same name +
+
+ )} + +
+
+ +
+
toggleSelectAll(!isAllSelected)}> + toggleSelectAll(e.target.checked)} + onClick={(e) => e.stopPropagation()} + data-testid="env-import-select-all" + /> + Select all +
+
+ + {/* Duplicates Group */} + {parsedData.duplicates.length > 0 && ( +
0 ? 'has-border-bottom' : ''}`}> +
+
toggleGroupExpanded('duplicates')}> + {expandedGroups.duplicates ? : } + Duplicates + {parsedData.duplicates.length} +
+ setGroupResolution('copy') }, + { id: 'replace', label: 'Replace existing', onClick: () => setGroupResolution('replace') } + ]} + selectedItemId={getDropdownValue() !== 'Custom' ? getDropdownValue() : null} + > + + + {getDropdownValue() === 'Custom' ? 'Custom' : getDropdownValue() === 'copy' ? 'Import as copy' : 'Replace existing'} + + + + +
+ {expandedGroups.duplicates && ( +
+ {filteredDuplicates.map((env, idx) => { + const globalIdx = [...parsedData.new, ...parsedData.duplicates].indexOf(env); + const isSelected = selectedIndices.has(globalIdx); + const resolution = resolutions[env.name]; + return ( +
+ +
+ setItemResolution(env.name, 'copy')} + title="Import as copy" + data-testid="env-import-copy-btn" + > + + + setItemResolution(env.name, 'replace')} + title="Replace existing" + data-testid="env-import-replace-btn" + > + + +
+
+ ); + })} + {filteredDuplicates.length === 0 && searchText && ( +
No matching duplicates
+ )} +
+ )} +
+ )} + + {/* New Group */} + {parsedData.new.length > 0 && ( +
+
+
toggleGroupExpanded('new')}> + {expandedGroups.new ? : } + New + {parsedData.new.length} +
+
+ {expandedGroups.new && ( +
+ {filteredNew.map((env, idx) => { + const globalIdx = [...parsedData.new, ...parsedData.duplicates].indexOf(env); + const isSelected = selectedIndices.has(globalIdx); + return ( +
+ +
+ ); + })} + {filteredNew.length === 0 && searchText && ( +
No matching new environments
+ )} +
+ )} +
+ )} +
+
-
+
); diff --git a/packages/bruno-app/src/ui/CountBadge/StyledWrapper.js b/packages/bruno-app/src/ui/CountBadge/StyledWrapper.js new file mode 100644 index 00000000000..0f604fe0e23 --- /dev/null +++ b/packages/bruno-app/src/ui/CountBadge/StyledWrapper.js @@ -0,0 +1,79 @@ +import styled, { css } from 'styled-components'; +import { rgba } from 'polished'; + +/** + * Variant styles for CountBadge. + * + * - default: surface0 bg, body text, border1 border — neutral counters + * - danger: #CE4F3B at 10% bg, #CE4F3B text, no border — error / conflict counts + * - warning: brand at 10% bg, brand text, no border — attention / highlight counts + * + * @see packages/bruno-app/src/themes/light/light.js — token schema + */ +const getVariantStyles = (props) => { + const { theme, $variant } = props; + + switch ($variant) { + case 'danger': + return css` + background-color: rgba(206, 79, 59, 0.1); + color: #CE4F3B; + border: none; + `; + case 'warning': + return css` + background-color: ${rgba(theme.brand, 0.1)}; + color: ${theme.brand}; + border: none; + `; + case 'default': + default: + return css` + background-color: ${theme.background.surface0}; + color: ${theme.text}; + border: 1px solid ${theme.border.border1}; + `; + } +}; + +/** + * Size presets. + * + * - sm: 16px badge, 3px horizontal padding, radius.sm (4px) + * - md: 20px badge, 4px horizontal padding, radius.base (6px) + */ +const getSizeStyles = (props) => { + const { theme, $size } = props; + + switch ($size) { + case 'md': + return css` + min-width: 20px; + min-height: 20px; + padding: 0 4px; + border-radius: ${theme.border.radius.base}; + `; + case 'sm': + default: + return css` + min-width: ${theme.font.size.lg}; + min-height: ${theme.font.size.lg}; + padding: 0 3px; + border-radius: ${theme.border.radius.sm}; + `; + } +}; + +const StyledWrapper = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 500; + line-height: 1; + white-space: nowrap; + ${(props) => getSizeStyles(props)} + ${(props) => getVariantStyles(props)} +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/ui/CountBadge/index.js b/packages/bruno-app/src/ui/CountBadge/index.js new file mode 100644 index 00000000000..2c6332d42f8 --- /dev/null +++ b/packages/bruno-app/src/ui/CountBadge/index.js @@ -0,0 +1,26 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +/** + * CountBadge — small pill-shaped badge for displaying counts. + * + * Props: + * - children: the count or label to display + * - variant: visual style — 'default' | 'danger' | 'warning' (default: 'default') + * - size: size preset — 'sm' | 'md' (default: 'sm') + * - className: passthrough for additional styling + * + * @example + * {totalEnvironments} + * {errorCount} + * {warningCount} + */ +const CountBadge = ({ children, variant = 'default', size = 'sm', className = '', ...rest }) => { + return ( + + {children} + + ); +}; + +export default CountBadge; diff --git a/packages/bruno-app/src/ui/index.js b/packages/bruno-app/src/ui/index.js index 49481248ef3..be04e68b161 100644 --- a/packages/bruno-app/src/ui/index.js +++ b/packages/bruno-app/src/ui/index.js @@ -3,6 +3,7 @@ // Prefer this over deep paths, and don't reach into a component's internals. export { default as ActionIcon } from './ActionIcon'; export { default as Button } from './Button'; +export { default as CountBadge } from './CountBadge'; export { default as ErrorBanner } from './ErrorBanner'; export { default as HeightBoundContainer } from './HeightBoundContainer'; export { default as MenuDropdown } from './MenuDropdown'; diff --git a/packages/bruno-app/src/utils/importers/bruno-environment.js b/packages/bruno-app/src/utils/importers/bruno-environment.js index 6dd928ef87d..f3d234bb104 100644 --- a/packages/bruno-app/src/utils/importers/bruno-environment.js +++ b/packages/bruno-app/src/utils/importers/bruno-environment.js @@ -1,7 +1,7 @@ import { BrunoError } from 'utils/common/error'; import { buildEnvVariable, dedupeImportedSecrets } from 'utils/environments'; -const validateBrunoEnvironment = (env) => { +const validateBrunoEnvironment = (env, filePath, fileName) => { if (!env || typeof env !== 'object') { throw new BrunoError('Invalid environment: expected an object'); } @@ -25,54 +25,69 @@ const validateBrunoEnvironment = (env) => { return { name: env.name || 'Imported Environment', variables: dedupeImportedSecrets(variables), - color: env.color + color: env.color, + filePath, + fileName }; }; -const processEnvironmentData = (data, fileName) => { +const processEnvironmentData = (data, fileName, filePath) => { + const valid = []; + const invalid = []; + try { // Handle new single-file format with environments array if (data.info && data.info.type === 'bruno-environment' && Array.isArray(data.environments)) { - return data.environments.map((env, index) => { + data.environments.forEach((env, index) => { try { - return validateBrunoEnvironment(env); + valid.push(validateBrunoEnvironment(env, filePath, fileName)); } catch (err) { - throw new BrunoError(`Error in environment ${index + 1} from ${fileName}: ${err.message}`); + invalid.push({ fileName, error: err.message }); } }); + return { valid, invalid }; } // Handle array of environments (old format) if (Array.isArray(data)) { - return data.map((env, index) => { + data.forEach((env, index) => { try { - return validateBrunoEnvironment(env); + valid.push(validateBrunoEnvironment(env, filePath, fileName)); } catch (err) { - throw new BrunoError(`Error in environment ${index + 1} from ${fileName}: ${err.message}`); + invalid.push({ fileName, error: err.message }); } }); + return { valid, invalid }; } // Handle single environment object - return [validateBrunoEnvironment(data)]; + try { + valid.push(validateBrunoEnvironment(data, filePath, fileName)); + } catch (err) { + invalid.push({ fileName, error: err.message }); + } + return { valid, invalid }; } catch (err) { - throw new BrunoError(`Error processing ${fileName}: ${err.message}`); + invalid.push({ fileName, error: `Error processing ${fileName}: ${err.message}` }); + return { valid, invalid }; } }; const processFiles = (parsedFiles) => { - const allEnvironments = []; + const allValid = []; + const allInvalid = []; for (const parsedFile of parsedFiles) { try { - const environments = processEnvironmentData(parsedFile.content, parsedFile.fileName); - allEnvironments.push(...environments); + const { valid, invalid } = processEnvironmentData(parsedFile.content, parsedFile.fileName, parsedFile.filePath); + allValid.push(...valid); + allInvalid.push(...invalid); } catch (err) { - throw new BrunoError(`Failed to process ${parsedFile.fileName}: ${err.message}`); + allInvalid.push({ fileName: parsedFile.fileName, error: `Failed to process ${parsedFile.fileName}: ${err.message}` }); } } - return allEnvironments; + return { valid: allValid, invalid: allInvalid }; }; const importBrunoEnvironment = (parsedFiles) => { @@ -81,8 +96,8 @@ const importBrunoEnvironment = (parsedFiles) => { throw new BrunoError('No files provided'); } - const environments = processFiles(parsedFiles); - return environments; + const result = processFiles(parsedFiles); + return result; } catch (err) { console.error(err); throw err instanceof BrunoError ? err : new BrunoError('Import Bruno environment failed'); diff --git a/packages/bruno-app/src/utils/importers/file-reader.js b/packages/bruno-app/src/utils/importers/file-reader.js index fb95445a14a..92bae55ea1b 100644 --- a/packages/bruno-app/src/utils/importers/file-reader.js +++ b/packages/bruno-app/src/utils/importers/file-reader.js @@ -29,7 +29,7 @@ const readFile = (file) => { fileReader.onload = (e) => { try { const parsed = JSON.parse(e.target.result); - resolve({ fileName: file.name, content: parsed }); + resolve({ fileName: file.name, filePath: file.path || file.webkitRelativePath || '', content: parsed }); } catch (err) { console.error(err); reject(new BrunoError(`Unable to parse JSON file: ${file.name}`)); diff --git a/packages/bruno-app/src/utils/importers/postman-environment.js b/packages/bruno-app/src/utils/importers/postman-environment.js index 08509fb18f0..a5ccef03244 100644 --- a/packages/bruno-app/src/utils/importers/postman-environment.js +++ b/packages/bruno-app/src/utils/importers/postman-environment.js @@ -4,19 +4,20 @@ import { dedupeImportedSecrets } from 'utils/environments'; const importEnvironment = async (parsedFiles) => { try { - const environments = []; + const valid = []; + const invalid = []; for (const parsedFile of parsedFiles) { try { const environment = postmanToBrunoEnvironment(parsedFile.content); - environments.push({ ...environment, variables: dedupeImportedSecrets(environment.variables) }); + valid.push({ ...environment, variables: dedupeImportedSecrets(environment.variables), filePath: parsedFile.filePath, fileName: parsedFile.fileName }); } catch (err) { console.error(`Error processing file: ${parsedFile.fileName}`, err); - throw new BrunoError(`Failed to process ${parsedFile.fileName}: ${err.message}`); + invalid.push({ fileName: parsedFile.fileName, error: err.message }); } } - return environments; + return { valid, invalid }; } catch (err) { console.log(err); throw err instanceof BrunoError ? err : new BrunoError('Import Environment failed'); diff --git a/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts b/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts index 752bffe2cdb..37c0fe6fe0d 100644 --- a/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts +++ b/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts @@ -38,6 +38,7 @@ test.describe.serial('Collection Environment Import Tests', () => { await page.getByTestId('import-environment').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(singleEnvFile); + await page.getByTestId('import-environment-modal-submit-btn').click(); }); await test.step('Verify imported environment and variables', async () => { @@ -104,6 +105,7 @@ test.describe.serial('Collection Environment Import Tests', () => { await page.getByTestId('import-environment').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(multiEnvFile); + await page.getByTestId('import-environment-modal-submit-btn').click(); const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); await expect(envTab).toBeVisible(); diff --git a/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts b/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts index acecbadac5a..d7bb7d01d3e 100644 --- a/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts +++ b/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts @@ -52,6 +52,7 @@ test.describe.serial('Global Environment Import Tests', () => { await page.locator('[data-testid="import-global-environment"]').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(singleEnvFile); + await page.getByTestId('import-global-environment-modal-submit-btn').click(); }); await test.step('Verify imported global environment and variables', async () => { @@ -117,6 +118,7 @@ test.describe.serial('Global Environment Import Tests', () => { await page.locator('[data-testid="import-global-environment"]').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(multiEnvFile); + await page.getByTestId('import-global-environment-modal-submit-btn').click(); const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); await expect(envTab).toBeVisible(); diff --git a/tests/environments/import-environment/collection-env-import.spec.ts b/tests/environments/import-environment/collection-env-import.spec.ts index 9c09a67431b..a1042bda900 100644 --- a/tests/environments/import-environment/collection-env-import.spec.ts +++ b/tests/environments/import-environment/collection-env-import.spec.ts @@ -47,6 +47,7 @@ test.describe('Collection Environment Import Tests', () => { await page.getByTestId('import-environment').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(envFile); + await page.getByTestId('import-environment-modal-submit-btn').click(); // Wait for import to complete and environment settings modal to open await expect(page.locator('.current-environment')).toContainText('Test Collection Environment'); diff --git a/tests/environments/import-environment/env-color-import/env-color-import.spec.ts b/tests/environments/import-environment/env-color-import/env-color-import.spec.ts index 23d64e1c244..3c35dc9b000 100644 --- a/tests/environments/import-environment/env-color-import/env-color-import.spec.ts +++ b/tests/environments/import-environment/env-color-import/env-color-import.spec.ts @@ -43,6 +43,7 @@ test.describe.serial('Environment Color Import Tests', () => { await page.getByTestId('import-global-environment').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(envWithColorFile); + await page.getByTestId('import-global-environment-modal-submit-btn').click(); // Wait for the environment tab to appear const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); diff --git a/tests/environments/import-environment/global-env-import.spec.ts b/tests/environments/import-environment/global-env-import.spec.ts index f3f733c3c48..5a30abadfdd 100644 --- a/tests/environments/import-environment/global-env-import.spec.ts +++ b/tests/environments/import-environment/global-env-import.spec.ts @@ -41,6 +41,7 @@ test.describe('Global Environment Import Tests', () => { await page.locator('[data-testid="import-global-environment"]').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(globalEnvFile); + await page.getByTestId('import-global-environment-modal-submit-btn').click(); // Wait for import to complete and global environment settings modal to open await expect(page.locator('.current-environment')).toContainText('Test Global Environment'); diff --git a/tests/environments/import-environment/name-conflicts/fixtures/development-env.json b/tests/environments/import-environment/name-conflicts/fixtures/development-env.json new file mode 100644 index 00000000000..036b722e75c --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/fixtures/development-env.json @@ -0,0 +1,6 @@ +{ + "name": "Development", + "variables": [ + { "name": "api_url", "value": "http://localhost:3000", "enabled": true, "secret": false } + ] +} diff --git a/tests/environments/import-environment/name-conflicts/fixtures/duplicate-names-in-batch.json b/tests/environments/import-environment/name-conflicts/fixtures/duplicate-names-in-batch.json new file mode 100644 index 00000000000..1121ad6cede --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/fixtures/duplicate-names-in-batch.json @@ -0,0 +1,7 @@ +{ + "info": { "type": "bruno-environment" }, + "environments": [ + { "name": "Test", "variables": [{ "name": "api_url", "value": "http://localhost:3000", "enabled": true }] }, + { "name": "Test", "variables": [{ "name": "api_url", "value": "http://test.local", "enabled": true }] } + ] +} diff --git a/tests/environments/import-environment/name-conflicts/fixtures/invalid-env.json b/tests/environments/import-environment/name-conflicts/fixtures/invalid-env.json new file mode 100644 index 00000000000..e0e482c65f1 --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/fixtures/invalid-env.json @@ -0,0 +1,4 @@ +{ + "name": "Invalid Env", + "invalidKey": "No variables array here" +} diff --git a/tests/environments/import-environment/name-conflicts/fixtures/production-env-updated.json b/tests/environments/import-environment/name-conflicts/fixtures/production-env-updated.json new file mode 100644 index 00000000000..823845344e3 --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/fixtures/production-env-updated.json @@ -0,0 +1,7 @@ +{ + "name": "Production", + "variables": [ + { "name": "api_url", "value": "https://api.updated.example.com", "enabled": true, "secret": false }, + { "name": "api_version", "value": "v2", "enabled": true, "secret": false } + ] +} diff --git a/tests/environments/import-environment/name-conflicts/fixtures/production-env.json b/tests/environments/import-environment/name-conflicts/fixtures/production-env.json new file mode 100644 index 00000000000..79e1e6add13 --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/fixtures/production-env.json @@ -0,0 +1,8 @@ +{ + "name": "Production", + "variables": [ + { "name": "api_url", "value": "https://api.example.com", "enabled": true, "secret": false }, + { "name": "api_key", "value": "original-secret", "enabled": true, "secret": true } + ], + "color": "#ff0000" +} diff --git a/tests/environments/import-environment/name-conflicts/fixtures/staging-env-updated.json b/tests/environments/import-environment/name-conflicts/fixtures/staging-env-updated.json new file mode 100644 index 00000000000..275dc74ea03 --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/fixtures/staging-env-updated.json @@ -0,0 +1,6 @@ +{ + "name": "Staging", + "variables": [ + { "name": "api_url", "value": "https://staging-v2.example.com", "enabled": true, "secret": false } + ] +} diff --git a/tests/environments/import-environment/name-conflicts/fixtures/staging-env.json b/tests/environments/import-environment/name-conflicts/fixtures/staging-env.json new file mode 100644 index 00000000000..deb99e27d9b --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/fixtures/staging-env.json @@ -0,0 +1,6 @@ +{ + "name": "Staging", + "variables": [ + { "name": "api_url", "value": "https://staging.example.com", "enabled": true, "secret": false } + ] +} diff --git a/tests/environments/import-environment/name-conflicts/name-conflicts.spec.ts b/tests/environments/import-environment/name-conflicts/name-conflicts.spec.ts new file mode 100644 index 00000000000..1b60ef41ff7 --- /dev/null +++ b/tests/environments/import-environment/name-conflicts/name-conflicts.spec.ts @@ -0,0 +1,257 @@ +import path from 'path'; +import { test, expect, Page } from '../../../../playwright'; +import { + buildCommonLocators, + closeAllCollections, + createCollection, + importEnvironment, + openEnvironmentConfigTab, + openEnvironmentSelector +} from '../../../utils/page'; + +type EnvironmentScope = 'collection' | 'global'; + +const fixture = (name: string) => path.join(__dirname, 'fixtures', name); + +const openImportReview = async (page: Page, scope: EnvironmentScope, ...filePaths: string[]) => { + const locators = buildCommonLocators(page); + await openEnvironmentConfigTab(page, scope); + await locators.environment.importSettingsButton().click(); + await expect(locators.environment.importModal(scope)).toBeVisible(); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await locators.environment.importFileTrigger(scope).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); +}; + +test.describe('Import environment - name conflict handling', () => { + test.describe('collection scope', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('flags a name conflict and offers Replace / Import as Copy resolution', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-flag', await createTmpDir('name-conflict-flag')); + await importEnvironment(page, fixture('production-env.json'), 'collection'); + + await test.step('Re-importing the same name surfaces it as a duplicate', async () => { + await openImportReview(page, 'collection', fixture('production-env-updated.json')); + + await expect(locators.environment.importDuplicatesWarning()).toContainText('1 environment'); + await expect(locators.environment.importDuplicatesGroup()).toBeVisible(); + await expect(locators.environment.importDuplicatesCount()).toHaveText('1'); + await expect(locators.environment.importNewGroup()).toHaveCount(0); + }); + + await test.step('Copy is the default resolution and can be switched to Replace', async () => { + const item = locators.environment.importReviewItem('Production'); + await expect(item).toBeVisible(); + await expect(locators.environment.importCopyButton('Production')).toHaveAttribute('title', 'Import as copy'); + await locators.environment.importReplaceButton('Production').click(); + }); + + await page.getByTestId('modal-close-button').click(); + }); + + test('Replace overwrites the existing environment in place', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-replace', await createTmpDir('name-conflict-replace')); + await importEnvironment(page, fixture('production-env.json'), 'collection'); + + await openImportReview(page, 'collection', fixture('production-env-updated.json')); + await locators.environment.importReplaceButton('Production').click(); + await locators.environment.importSubmitButton('collection').click(); + + await test.step('Only one Production environment remains, holding the new variables', async () => { + await expect(locators.environment.sidebarListItem('collection', 'Production')).toHaveCount(1); + await locators.environment.sidebarListItem('collection', 'Production').click(); + await expect(locators.environment.varRowValueCell('api_url')).toBeVisible(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://api.updated.example.com'); + await expect(locators.environment.varRow('api_version')).toBeVisible(); + }); + }); + + test('Import as Copy adds a suffixed environment without touching the original', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-copy', await createTmpDir('name-conflict-copy')); + await importEnvironment(page, fixture('production-env.json'), 'collection'); + + await openImportReview(page, 'collection', fixture('production-env-updated.json')); + // Copy is the default resolution. + await locators.environment.importSubmitButton('collection').click(); + + await test.step('Both the original and the copy exist side by side', async () => { + await expect(locators.environment.sidebarListItemExact('collection', 'Production')).toBeVisible(); + await expect(locators.environment.sidebarListItemExact('collection', 'Production copy')).toBeVisible(); + + await locators.environment.sidebarListItemExact('collection', 'Production').click(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://api.example.com'); + + await locators.environment.sidebarListItemExact('collection', 'Production copy').click(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://api.updated.example.com'); + }); + }); + + test('skipping a duplicate still imports the other new environments in the batch', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-skip', await createTmpDir('name-conflict-skip')); + await importEnvironment(page, fixture('production-env.json'), 'collection'); + + await openImportReview(page, 'collection', fixture('production-env-updated.json'), fixture('development-env.json')); + await locators.environment.importItemCheckbox('Production').uncheck(); + await locators.environment.importSubmitButton('collection').click(); + + await test.step('Development is imported; Production is left untouched', async () => { + await expect(locators.environment.sidebarListItem('collection', 'Development')).toBeVisible(); + await expect(locators.environment.sidebarListItem('collection', 'Production copy')).toHaveCount(0); + + await locators.environment.sidebarListItem('collection', 'Production').click(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://api.example.com'); + }); + }); + + test('the group dropdown applies one resolution to every duplicate at once', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-apply-all', await createTmpDir('name-conflict-apply-all')); + await importEnvironment(page, fixture('production-env.json'), 'collection'); + await openImportReview(page, 'collection', fixture('staging-env.json')); + await locators.environment.importSubmitButton('collection').click(); + + await openImportReview(page, 'collection', fixture('production-env-updated.json'), fixture('staging-env-updated.json')); + + await test.step('Selecting "Replace existing" from the group dropdown flips both items', async () => { + await expect(locators.environment.importDuplicatesCount()).toHaveText('2'); + await locators.environment.importGroupDropdownTrigger().click(); + await locators.environment.importGroupDropdownReplaceOption().click(); + + await expect(locators.environment.importReplaceButton('Production')).toHaveAttribute('title', 'Replace existing'); + }); + + await locators.environment.importSubmitButton('collection').click(); + + await test.step('Both existing environments were replaced, no copies created', async () => { + await expect(locators.environment.sidebarListItem('collection', 'Production')).toHaveCount(1); + await expect(locators.environment.sidebarListItem('collection', 'Staging')).toHaveCount(1); + + await locators.environment.sidebarListItem('collection', 'Production').click(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://api.updated.example.com'); + + await locators.environment.sidebarListItem('collection', 'Staging').click(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://staging-v2.example.com'); + }); + }); + + test('closing the review dialog cancels the whole import', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-cancel', await createTmpDir('name-conflict-cancel')); + await importEnvironment(page, fixture('production-env.json'), 'collection'); + + await openImportReview(page, 'collection', fixture('production-env-updated.json'), fixture('development-env.json')); + await expect(locators.environment.importModal('collection')).toBeVisible(); + + await page.getByTestId('modal-close-button').click(); + + await test.step('Nothing from the pending import was persisted', async () => { + await expect(locators.environment.importModal('collection')).toBeHidden(); + await expect(locators.environment.sidebarListItem('collection', 'Development')).toHaveCount(0); + + await locators.environment.sidebarListItem('collection', 'Production').click(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://api.example.com'); + }); + }); + + test('clicking outside the modal does not close it', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-backdrop', await createTmpDir('name-conflict-backdrop')); + await importEnvironment(page, fixture('production-env.json'), 'collection'); + + await openImportReview(page, 'collection', fixture('production-env-updated.json')); + const modal = locators.environment.importModal('collection'); + await expect(modal).toBeVisible(); + + await page.mouse.click(5, 5); + + await expect(modal).toBeVisible(); + + await page.getByTestId('modal-close-button').click(); + }); + + test('multiple environments with the same name in one batch are deduped without a conflict prompt', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-batch-dedupe', await createTmpDir('name-conflict-batch-dedupe')); + + await test.step('Import a file whose environments array has two entries named "Test"', async () => { + await openEnvironmentSelector(page, 'collection'); + await locators.environment.importEmptyStateButton().click(); + await expect(locators.environment.importModal('collection')).toBeVisible(); + const fileChooserPromise = page.waitForEvent('filechooser'); + await locators.environment.importFileTrigger('collection').click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(fixture('duplicate-names-in-batch.json')); + }); + + await test.step('Both entries land in New, not Duplicates, still shown under their original name', async () => { + await expect(locators.environment.importDuplicatesGroup()).toHaveCount(0); + await expect(locators.environment.importNewCount()).toHaveText('2'); + await expect(locators.environment.importReviewItem('Test')).toHaveCount(2); + }); + + await locators.environment.importSubmitButton('collection').click(); + + await test.step('The name clash is only resolved once the import is committed', async () => { + await expect(locators.environment.sidebarListItemExact('collection', 'Test')).toBeVisible(); + await expect(locators.environment.sidebarListItemExact('collection', 'Test copy')).toBeVisible(); + }); + }); + + test('an invalid or unsupported file blocks the import and reports the error', async ({ page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-invalid', await createTmpDir('name-conflict-invalid')); + + await openEnvironmentSelector(page, 'collection'); + await locators.environment.importEmptyStateButton().click(); + const importModal = locators.environment.importModal('collection'); + await expect(importModal).toBeVisible(); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await locators.environment.importFileTrigger('collection').click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(fixture('invalid-env.json')); + + await expect(locators.toast.byMessage('One or more environment files have an invalid or unsupported format')).toBeVisible(); + await expect(importModal).toBeVisible(); + await expect(locators.environment.sidebarListItem('collection', 'Invalid Env')).toHaveCount(0); + + await page.getByTestId('modal-close-button').click(); + }); + }); + + test.describe('global scope', () => { + test('duplicate handling (Replace and Import as Copy) works the same way for global environments', async ({ newPage: page, createTmpDir }) => { + const locators = buildCommonLocators(page); + await createCollection(page, 'name-conflict-global', await createTmpDir('name-conflict-global')); + await importEnvironment(page, fixture('production-env.json'), 'global'); + + await test.step('Replace overwrites the existing global environment', async () => { + await openImportReview(page, 'global', fixture('production-env-updated.json')); + await locators.environment.importReplaceButton('Production').click(); + await locators.environment.importSubmitButton('global').click(); + + await expect(locators.environment.sidebarListItem('global', 'Production')).toHaveCount(1); + await locators.environment.sidebarListItem('global', 'Production').click(); + await expect(locators.environment.varRowLine('api_url')).toHaveText('https://api.updated.example.com'); + }); + + await test.step('Import as Copy adds a second global environment alongside the original', async () => { + await openImportReview(page, 'global', fixture('production-env.json')); + await locators.environment.importSubmitButton('global').click(); + + await expect(locators.environment.sidebarListItem('global', 'Production copy')).toBeVisible(); + }); + + await closeAllCollections(page); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 2ccc5572ee8..554ddc24ee9 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -802,6 +802,8 @@ const importEnvironment = async ( const fileChooser = await fileChooserPromise; await fileChooser.setFiles(filePath); + await locators.environment.importSubmitButton(type).click(); + const settingsTab = type === 'global' ? locators.environment.globalEnvTab() : locators.environment.collectionEnvTab(); diff --git a/tests/utils/page/environments/index.ts b/tests/utils/page/environments/index.ts index 172e2881d5a..855ffbd5457 100644 --- a/tests/utils/page/environments/index.ts +++ b/tests/utils/page/environments/index.ts @@ -96,6 +96,7 @@ export const buildEnvironmentLocators = (page: Page) => ({ saveAndClose: () => page.getByTestId('env-unsaved-save-and-close') }, importEmptyStateButton: () => page.getByTestId('empty-state-import-env-btn'), + importSettingsButton: () => page.locator('.environments-container .sidebar button[title="Import environment"]'), importModal: (scope: 'collection' | 'global') => page.getByTestId(scope === 'global' ? 'import-global-environment-modal' : 'import-environment-modal'), importFileTrigger: (scope: 'collection' | 'global') => @@ -104,8 +105,31 @@ export const buildEnvironmentLocators = (page: Page) => ({ page .getByTestId(scope === 'global' ? 'workspace-env-list-item' : 'collection-env-list-item') .filter({ hasText: name }), + // Exact-name variant — `sidebarListItem` substring-matches, so "Production" also matches + // "Production copy"; use this when a batch can contain both a name and its copy suffix. + sidebarListItemExact: (scope: 'collection' | 'global', name: string) => + page + .getByTestId(scope === 'global' ? 'workspace-env-list-item' : 'collection-env-list-item') + .filter({ has: page.getByText(name, { exact: true }) }), varRowEnabledCheckbox: (name: string) => - page.getByTestId(`env-var-row-${name}`).getByTestId('env-var-enabled-checkbox') + page.getByTestId(`env-var-row-${name}`).getByTestId('env-var-enabled-checkbox'), + importSubmitButton: (scope: 'collection' | 'global') => + page.getByTestId(scope === 'global' ? 'import-global-environment-modal-submit-btn' : 'import-environment-modal-submit-btn'), + importTotalCount: () => page.getByTestId('env-import-total-count'), + importDuplicatesWarning: () => page.locator('.warning-block'), + importDuplicatesGroup: () => page.locator('.group-container').filter({ hasText: 'Duplicates' }), + importDuplicatesCount: () => page.getByTestId('env-import-duplicates-count'), + importNewGroup: () => page.locator('.group-container').filter({ hasText: 'New' }), + importNewCount: () => page.getByTestId('env-import-new-count'), + importSelectAllCheckbox: () => page.getByTestId('env-import-select-all'), + importSelectedCount: () => page.locator('.footer-left-content'), + importReviewItem: (name: string) => page.getByTestId('env-import-item').filter({ has: page.getByText(name, { exact: true }) }), + importItemCheckbox: (name: string) => buildEnvironmentLocators(page).importReviewItem(name).getByTestId('env-import-item-checkbox'), + importCopyButton: (name: string) => buildEnvironmentLocators(page).importReviewItem(name).getByTestId('env-import-copy-btn'), + importReplaceButton: (name: string) => buildEnvironmentLocators(page).importReviewItem(name).getByTestId('env-import-replace-btn'), + importGroupDropdownTrigger: () => page.getByTestId('env-import-group-dropdown'), + importGroupDropdownCopyOption: () => page.getByTestId('menu-dropdown-copy'), + importGroupDropdownReplaceOption: () => page.getByTestId('menu-dropdown-replace') }); /** From 6ff54b4dc4661c4f15739bfddcd09498ee6483d6 Mon Sep 17 00:00:00 2001 From: Gopi-bruno Date: Fri, 21 Aug 2026 02:51:26 +0530 Subject: [PATCH 02/16] feat: updating styles for checkbox and warning messages --- .../ImportEnvironmentModal/StyledWrapper.js | 15 ++++++++++--- .../Common/ImportEnvironmentModal/index.js | 21 ++++++++----------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js index b26f819c9d1..ee4c891ad73 100644 --- a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/StyledWrapper.js @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { rgba } from 'polished'; export const StyledWrapper = styled.div` /* Upload Step */ @@ -84,7 +85,7 @@ export const StyledWrapper = styled.div` } .warning-block { - background-color: ${(props) => props.theme.colors.bg.yellow}; + background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.1)}; border-bottom: 1px solid ${(props) => props.theme.border.border0}; color: ${(props) => props.theme.colors.text.base}; padding: 0.75rem; @@ -98,13 +99,17 @@ export const StyledWrapper = styled.div` letter-spacing: normal; display: flex; align-items: center; - color: ${(props) => props.theme.colors.text.yellow}; + color: ${(props) => props.theme.colors.text.base}; margin-bottom: 0.5rem; } .warning-title { font-weight: 700; } + + .warning-icon { + color: ${(props) => props.theme.colors.text.yellow}; + } .search-block { display: flex; @@ -130,11 +135,14 @@ export const StyledWrapper = styled.div` .select-all-checkbox { margin-right: 0.5rem; cursor: pointer; + background: transparent; } .select-all-text { - font-size: 0.875rem; + font-size: ${(props) => props.theme.font.size.base}; font-weight: 500; + line-height: 20px; + letter-spacing: 0%; } .group-container { @@ -190,6 +198,7 @@ export const StyledWrapper = styled.div` margin-right: 0.75rem; margin-top: 0.25rem; cursor: pointer; + background: transparent; } .env-item-content { diff --git a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js index e927d103cad..f59c80b4280 100644 --- a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js +++ b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js @@ -4,6 +4,7 @@ import MenuDropdown from 'ui/MenuDropdown'; import { DropdownTrigger, ResolutionButton, StyledWrapper } from './StyledWrapper'; import Portal from 'components/Portal'; import Modal from 'components/Modal'; +import Checkbox from 'components/Checkbox'; import SearchInput from 'components/SearchInput'; import IconAlertTriangleFilled from 'components/Icons/IconAlertTriangleFilled'; import toast from 'react-hot-toast'; @@ -329,7 +330,7 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn {parsedData.duplicates.length > 0 && (
- + {parsedData.duplicates.length} {pluralizeWord('environment', parsedData.duplicates.length)}  already {parsedData.duplicates.length > 1 ? 'exist' : 'exists'} with the same name
@@ -350,13 +351,11 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn />
toggleSelectAll(!isAllSelected)}> - toggleSelectAll(e.target.checked)} - onClick={(e) => e.stopPropagation()} - data-testid="env-import-select-all" + dataTestId="env-import-select-all" /> Select all
@@ -395,12 +394,11 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn return (