mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 06:34:06 +00:00
feat: move import collection from git url and spec url from enterprise edition to opensource (#7127)
* feat: move import collection from git url and spec url from enterprise edition to opensource
* fix: corrected a typo
* test: add unit and e2e tests for import collection migration
* fix: guard against missing userAgentData platform in getOSName — Default platform to '' to prevent TypeError when navigator.userAgentData is unavailable (GitNotFoundModal/index.js)
fix: UID mismatch between status tracking and UI rendering in bulk import — Preserve synthetic file-${index} UID on converted collections so initialStatus, rename tracking, and the render loop all use the same key (BulkImportCollectionLocation/index.js)
fix: isConfirmDisabled returning non-boolean value — Changed .length checks to explicit comparisons (> 0, === 0) so the function always returns true/false (CloneGitRespository/index.js)
fix: missing ipcRenderer declaration in cloneGitRepository and scanForBrunoFiles — Added const { ipcRenderer } = window; to both actions to prevent ReferenceError at runtime (collections/actions.js)
fix: use strict equality in filterItemsInCollection — Changed == to === for item.name and item.type comparisons (importers/common.js)
fix: variable shadowing in transformItemsInCollection and hydrateSeqInCollection — Renamed forEach callback parameter from collection to col to avoid shadowing the outer parameter (importers/common.js)
fix: scanForBrunoFiles traversing node_modules and .git directories — Added exclusion for node_modules and .git to match getCollectionStats pattern, preventing app freezes on large repos (filesystem.js)
fix: diff hunk header using string character count instead of line count — Preserved prefixedLines array to compute lineCount before joining, so the @@ header has the correct line count (git.js)
fix: test locators not scoped to modal in bulk import e2e test — Changed page.getByTestId to bulkImportModal.getByTestId for grouping dropdown interactions (002-all-collection-types.spec.ts)
fix: missing afterEach cleanup in GitHub repository import test — Added closeAllCollections hook to match sibling test specs, replaced unused dotenv/config import (github-repository-import.spec.ts)
* fix: batch name tracking and git utility fixes
- Fix usedNamesInBatch tracking original name instead of final name, which
could produce duplicate environment names within the same batch
(BulkImportCollectionLocation/index.js)
- Remove unused lodash import (git.js)
- Add missing early return in fetchRemotes when gitRootPath is falsy,
preventing getSimpleGitInstanceForPath from running with undefined (git.js)
* fix: correct variable naming and state management in CloneGitRepository component
- Renamed `collectionpaths` to `collectionPaths` for consistency and clarity.
- Updated references throughout the component to use the corrected variable name.
- Removed error toast notification to streamline error handling during repository cloning.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,6 +49,7 @@ bruno.iml
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
132
package-lock.json
generated
132
package-lock.json
generated
@@ -23,7 +23,8 @@
|
||||
"packages/bruno-filestore"
|
||||
],
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1"
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.3.2",
|
||||
@@ -5521,6 +5522,44 @@
|
||||
"jsep": "^0.4.0||^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@kwsites/file-exists": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@kwsites/file-exists/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kwsites/file-exists/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kwsites/promise-deferred": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
|
||||
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lydell/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
|
||||
@@ -17363,6 +17402,25 @@
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/git-up": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/git-up/-/git-up-7.0.0.tgz",
|
||||
"integrity": "sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-ssh": "^1.4.0",
|
||||
"parse-url": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/git-url-parse": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-14.1.0.tgz",
|
||||
"integrity": "sha512-8xg65dTxGHST3+zGpycMMFZcoTzAdZ2dOtu4vmgIfkTFnVHBxHMzBC2L1k8To7EmrSiHesT8JgPLT91VKw1B5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"git-up": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/github-markdown-css": {
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz",
|
||||
@@ -18903,6 +18961,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-ssh": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz",
|
||||
"integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocols": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -22427,6 +22494,24 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-path": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz",
|
||||
"integrity": "sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocols": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-url": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz",
|
||||
"integrity": "sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse-path": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
|
||||
@@ -23990,6 +24075,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocols": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz",
|
||||
"integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -26752,6 +26843,44 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-git": {
|
||||
"version": "3.30.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz",
|
||||
"integrity": "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kwsites/file-exists": "^1.1.1",
|
||||
"@kwsites/promise-deferred": "^1.1.1",
|
||||
"debug": "^4.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/steveukx/git-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-git/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/simple-git/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -33329,6 +33458,7 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.8",
|
||||
"qs": "^6.14.1",
|
||||
"simple-git": "^3.22.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1"
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
pre {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IpcErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<StyledWrapper>
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</Portal>
|
||||
</StyledWrapper>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpcErrorModal;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal/index';
|
||||
|
||||
const getOSName = () => {
|
||||
const platform = window.navigator.userAgentData?.platform || '';
|
||||
if (platform.startsWith('Win')) {
|
||||
return 'Windows';
|
||||
} else if (platform.startsWith('Mac')) {
|
||||
return 'macOS';
|
||||
} else if (platform.startsWith('Linux')) {
|
||||
return 'Linux';
|
||||
} else {
|
||||
return 'your OS';
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadUrl = (os) => {
|
||||
switch (os) {
|
||||
case 'Windows':
|
||||
return 'https://git-scm.com/download/win';
|
||||
case 'macOS':
|
||||
return 'https://git-scm.com/download/mac';
|
||||
case 'Linux':
|
||||
return 'https://git-scm.com/download/linux';
|
||||
default:
|
||||
return 'https://git-scm.com/download';
|
||||
}
|
||||
};
|
||||
|
||||
const GitNotFoundModal = ({ onClose }) => {
|
||||
const osName = getOSName();
|
||||
const downloadUrl = getDownloadUrl(osName);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Git Not Found"
|
||||
handleCancel={onClose}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div>
|
||||
<p>Git was not detected on your system. You need to install Git to proceed.</p>
|
||||
<p className="mt-2">
|
||||
You can download Git for <strong>{osName}</strong> here:
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className="text-blue-600 cursor-pointer border-b border-blue-600"
|
||||
onClick={() => window.open(downloadUrl, '_blank')}
|
||||
>
|
||||
Download Git for {osName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitNotFoundModal;
|
||||
@@ -0,0 +1,28 @@
|
||||
import styled from 'styled-components';
|
||||
import { darken } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.current-group {
|
||||
background-color: ${(props) => props.theme.background.surface1};
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid ${(props) => props.theme.background.surface2};
|
||||
}
|
||||
|
||||
.current-group:hover {
|
||||
background-color: ${(props) => darken(0.03, props.theme.background.surface1)};
|
||||
border-color: ${(props) => darken(0.03, props.theme.background.surface2)};
|
||||
}
|
||||
|
||||
/* Fix dropdown positioning */
|
||||
[data-tippy-root] {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.bruno-modal-footer {
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,887 @@
|
||||
import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Modal from 'components/Modal';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
import { IconX, IconLoader2, IconCheck, IconCaretDown } from '@tabler/icons';
|
||||
import InfoTip from 'components/InfoTip/index';
|
||||
import Help from 'components/Help';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { postmanToBruno } from 'utils/importers/postman-collection';
|
||||
import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection';
|
||||
import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
|
||||
import { processBrunoCollection } from 'utils/importers/bruno-collection';
|
||||
import { wsdlToBruno } from '@usebruno/converters';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const STATUS = {
|
||||
LOADING: 'loading',
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error'
|
||||
};
|
||||
|
||||
const IMPORT_TYPE = {
|
||||
BULK: 'bulk',
|
||||
MULTIPLE: 'multiple'
|
||||
};
|
||||
|
||||
const groupingOptions = [
|
||||
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
|
||||
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
|
||||
];
|
||||
|
||||
// Extract collection name from raw data
|
||||
const getCollectionName = (format, rawData) => {
|
||||
if (!rawData) return 'Collection';
|
||||
|
||||
switch (format) {
|
||||
case 'openapi':
|
||||
return rawData.info?.title || 'OpenAPI Collection';
|
||||
case 'postman':
|
||||
return rawData.info?.name || rawData.collection?.info?.name || 'Postman Collection';
|
||||
case 'insomnia':
|
||||
// For Insomnia v4 format, name is in the workspace resource
|
||||
if (rawData.resources && Array.isArray(rawData.resources)) {
|
||||
const workspace = rawData.resources.find((r) => r._type === 'workspace');
|
||||
if (workspace?.name) {
|
||||
return workspace.name;
|
||||
}
|
||||
}
|
||||
// Fallback to root name property
|
||||
return rawData.name || 'Insomnia Collection';
|
||||
case 'bruno':
|
||||
return rawData.name || 'Bruno Collection';
|
||||
case 'wsdl':
|
||||
return 'WSDL Collection';
|
||||
default:
|
||||
return 'Collection';
|
||||
}
|
||||
};
|
||||
|
||||
// Convert raw data to Bruno collection format
|
||||
const convertCollection = async (format, rawData, groupingType) => {
|
||||
let collection;
|
||||
|
||||
switch (format) {
|
||||
case 'openapi':
|
||||
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
|
||||
break;
|
||||
case 'wsdl':
|
||||
collection = await wsdlToBruno(rawData);
|
||||
break;
|
||||
case 'postman':
|
||||
collection = await postmanToBruno(rawData);
|
||||
break;
|
||||
case 'insomnia':
|
||||
collection = convertInsomniaToBruno(rawData);
|
||||
break;
|
||||
case 'bruno':
|
||||
collection = await processBrunoCollection(rawData);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown collection format');
|
||||
}
|
||||
|
||||
return collection;
|
||||
};
|
||||
|
||||
export function normalizeName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique name by adding "copy" suffix if the name already exists.
|
||||
* @param {string} baseName - The original name
|
||||
* @param {function} checkExists - Function that returns true if name exists
|
||||
* @returns {string} - Unique name with "copy" suffix if needed
|
||||
*/
|
||||
export function generateUniqueName(baseName, checkExists) {
|
||||
const normalizedBase = normalizeName(baseName);
|
||||
if (!checkExists(normalizedBase)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
let counter = 1;
|
||||
let uniqueName = `${baseName} copy`;
|
||||
|
||||
while (checkExists(normalizeName(uniqueName))) {
|
||||
counter++;
|
||||
uniqueName = `${baseName} copy ${counter}`;
|
||||
}
|
||||
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
export const BulkImportCollectionLocation = ({
|
||||
onClose,
|
||||
handleSubmit,
|
||||
importData
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultCollectionLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
|
||||
const [status, setStatus] = useState({});
|
||||
const [errorMessages, setErrorMessages] = useState({});
|
||||
const [importStarted, setImportStarted] = useState(false);
|
||||
const [environmentStatus, setEnvironmentStatus] = useState({});
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [selectedError, setSelectedError] = useState(null);
|
||||
const [applyToGlobal, setApplyToGlobal] = useState(true);
|
||||
const [applyToCollection, setApplyToCollection] = useState(false);
|
||||
const [groupingType, setGroupingType] = useState('tags');
|
||||
const [collectionFormat, setCollectionFormat] = useState('bru');
|
||||
const [renamedCollectionNames, setRenamedCollectionNames] = useState({});
|
||||
const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({});
|
||||
|
||||
// Extract data based on import type
|
||||
const importType = importData?.type;
|
||||
const isBulkImport = importType === IMPORT_TYPE.BULK;
|
||||
const isMultipleImport = importType === IMPORT_TYPE.MULTIPLE;
|
||||
|
||||
// For bulk import (ZIP files)
|
||||
const importedCollectionFromBulk = isBulkImport ? importData.collection : [];
|
||||
const importedEnvironmentFromBulk = isBulkImport ? (importData.environment || []) : [];
|
||||
|
||||
// For multiple files import
|
||||
const filesData = isMultipleImport ? importData.filesData : [];
|
||||
const hasOpenApiSpec = filesData.some((f) => f.type === 'openapi');
|
||||
|
||||
// Create unified collection structure for display
|
||||
const importedCollection = isMultipleImport
|
||||
? filesData.map((fileData, index) => ({
|
||||
uid: `file-${index}`,
|
||||
name: getCollectionName(fileData.type, fileData.data),
|
||||
_fileData: fileData
|
||||
}))
|
||||
: importedCollectionFromBulk;
|
||||
|
||||
const importedEnvironment = isBulkImport ? importedEnvironmentFromBulk : [];
|
||||
|
||||
const globalEnvironments = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
const existingCollections = useSelector((state) => state?.collections?.collections || []);
|
||||
|
||||
// Initialize selected items based on import type
|
||||
const [selectedCollections, setSelectedCollections] = useState(importedCollection.map((col) => col.uid));
|
||||
const [selectedEnvironments, setSelectedEnvironments] = useState(isBulkImport ? importedEnvironmentFromBulk.map((env) => env.uid) : []);
|
||||
|
||||
const allCollectionsSelected = selectedCollections.length === importedCollection.length;
|
||||
const allEnvironmentsSelected = selectedEnvironments.length === importedEnvironment.length;
|
||||
|
||||
// Sort collections to show selected items first, then unselected items
|
||||
// This helps users see their selections at the top of the list
|
||||
const sortedCollections = useMemo(() => {
|
||||
const arr = [...importedCollection];
|
||||
arr.sort((a, b) => {
|
||||
const aSelected = selectedCollections.includes(a.uid);
|
||||
const bSelected = selectedCollections.includes(b.uid);
|
||||
// Convert boolean to number: true = 1, false = 0
|
||||
// bSelected - aSelected means: selected items (1) come before unselected (0)
|
||||
return Number(bSelected) - Number(aSelected);
|
||||
});
|
||||
return arr;
|
||||
}, [importedCollection, selectedCollections]);
|
||||
|
||||
// Sort environments to show selected items first, then unselected items
|
||||
// This helps users see their selections at the top of the list
|
||||
const sortedEnvironments = useMemo(() => {
|
||||
const arr = [...importedEnvironment];
|
||||
arr.sort((a, b) => {
|
||||
const aSelected = selectedEnvironments.includes(a.uid);
|
||||
const bSelected = selectedEnvironments.includes(b.uid);
|
||||
// selected (true) should come before unselected (false)
|
||||
return Number(bSelected) - Number(aSelected);
|
||||
});
|
||||
return arr;
|
||||
}, [importedEnvironment, selectedEnvironments]);
|
||||
|
||||
const importStatus = useMemo(() => {
|
||||
const selectedSet = new Set(selectedCollections);
|
||||
const totalSelected = selectedCollections.length;
|
||||
const failedCount = Object.entries(status).reduce((acc, [uid, s]) => {
|
||||
return selectedSet.has(uid) && s === STATUS.ERROR ? acc + 1 : acc;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
totalSelected,
|
||||
failedCount
|
||||
};
|
||||
}, [status, selectedCollections]);
|
||||
|
||||
// Handlers
|
||||
const handleCollectionToggle = (uid) => {
|
||||
setSelectedCollections((prev) =>
|
||||
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
|
||||
);
|
||||
};
|
||||
const handleEnvironmentToggle = (uid) => {
|
||||
setSelectedEnvironments((prev) =>
|
||||
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
|
||||
);
|
||||
};
|
||||
const handleSelectAllCollections = (e) => {
|
||||
setSelectedCollections(e.target.checked ? importedCollection.map((col) => col.uid) : []);
|
||||
};
|
||||
const handleSelectAllEnvironments = (e) => {
|
||||
setSelectedEnvironments(
|
||||
e.target.checked ? importedEnvironment.map((env) => env.uid) : []
|
||||
);
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
|
||||
const GroupingDropdownIcon = forwardRef((props, ref) => {
|
||||
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-between w-full current-group" data-testid="grouping-dropdown">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
|
||||
</div>
|
||||
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
collectionLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
collectionLocation: Yup.string()
|
||||
.min(1, 'must be at least 1 character')
|
||||
.max(500, 'must be 500 characters or less')
|
||||
.required('Location is required')
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
let filteredCollections = [];
|
||||
const selectedItems = importedCollection.filter((col) => selectedCollections.includes(col.uid));
|
||||
|
||||
if (isMultipleImport) {
|
||||
// Convert selected files to collections at submit time
|
||||
for (const item of selectedItems) {
|
||||
try {
|
||||
const collection = await convertCollection(item._fileData.type, item._fileData.data, groupingType);
|
||||
if (collection) {
|
||||
// Preserve the synthetic UID so status tracking, rename tracking,
|
||||
// and UI rendering all use the same key
|
||||
collection.uid = item.uid;
|
||||
filteredCollections.push(collection);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to convert file ${item._fileData.file.name}:`, err);
|
||||
}
|
||||
}
|
||||
} else if (isBulkImport) {
|
||||
// For bulk import, use selected collections directly
|
||||
filteredCollections = selectedItems;
|
||||
}
|
||||
|
||||
const initialStatus = {};
|
||||
filteredCollections.forEach((col) => {
|
||||
initialStatus[col.uid] = STATUS.LOADING;
|
||||
});
|
||||
|
||||
setStatus(initialStatus);
|
||||
setErrorMessages({});
|
||||
|
||||
const filteredEnvironments = importedEnvironment.filter((env) =>
|
||||
selectedEnvironments.includes(env.uid)
|
||||
);
|
||||
|
||||
// Handle duplicate collection names by renaming new ones to a unique "{originalName} N" suffix
|
||||
const existingCollectionNames = new Set(existingCollections.map((col) => normalizeName(col.name)));
|
||||
const usedNames = new Set();
|
||||
const renamedNames = {};
|
||||
|
||||
filteredCollections.forEach((collection) => {
|
||||
const originalName = collection.name;
|
||||
let finalName = originalName;
|
||||
let index = 0;
|
||||
|
||||
while (existingCollectionNames.has(normalizeName(finalName)) || usedNames.has(normalizeName(finalName))) {
|
||||
finalName = `${originalName} ${index + 1}`;
|
||||
index++;
|
||||
}
|
||||
|
||||
collection.name = finalName;
|
||||
usedNames.add(normalizeName(finalName));
|
||||
// Store renamed name for summary display
|
||||
if (finalName !== originalName) {
|
||||
renamedNames[collection.uid] = finalName;
|
||||
}
|
||||
});
|
||||
|
||||
setRenamedCollectionNames(renamedNames);
|
||||
|
||||
// Process all selected environments and rename duplicates
|
||||
// Don't use getUniqueEnvironments as it filters out duplicates - we want to rename them instead
|
||||
const collectionRenamedEnvNames = {};
|
||||
const globalRenamedEnvNames = {};
|
||||
|
||||
if (applyToCollection) {
|
||||
// add selected environments to each selected collection
|
||||
// Rename duplicates with "copy" suffix instead of filtering them out
|
||||
filteredCollections.forEach((collection) => {
|
||||
const existingNamesSet = new Set((collection.environments || []).map((e) => normalizeName(e?.name)));
|
||||
const usedNamesInBatch = new Set();
|
||||
|
||||
const envsForCollection = filteredEnvironments.map((env) => {
|
||||
const originalName = env.name;
|
||||
const normalizedOriginalName = normalizeName(originalName);
|
||||
|
||||
// Check if name exists in collection or was already used in this batch
|
||||
const checkExists = (name) => existingNamesSet.has(name) || usedNamesInBatch.has(name);
|
||||
const finalName = generateUniqueName(originalName, checkExists);
|
||||
|
||||
// Track renamed name for summary display
|
||||
if (finalName !== originalName) {
|
||||
collectionRenamedEnvNames[env.uid] = finalName;
|
||||
}
|
||||
|
||||
usedNamesInBatch.add(normalizeName(finalName));
|
||||
existingNamesSet.add(normalizeName(finalName));
|
||||
return { ...env, name: finalName };
|
||||
});
|
||||
|
||||
collection.environments = envsForCollection;
|
||||
});
|
||||
|
||||
// Mark all collection environments as success (they're processed with the collection import)
|
||||
const envStatusUpdate = {};
|
||||
filteredEnvironments.forEach((env) => {
|
||||
envStatusUpdate[env.uid] = STATUS.SUCCESS;
|
||||
});
|
||||
setEnvironmentStatus((prev) => ({ ...prev, ...envStatusUpdate }));
|
||||
|
||||
if (Object.keys(collectionRenamedEnvNames).length > 0) {
|
||||
setRenamedEnvironmentNames((prev) => ({ ...prev, ...collectionRenamedEnvNames }));
|
||||
}
|
||||
}
|
||||
|
||||
if (applyToGlobal && filteredEnvironments.length > 0) {
|
||||
// Pre-compute unique names for all environments to avoid race conditions
|
||||
const existingGlobalNames = new Set((globalEnvironments || []).map((env) => normalizeName(env?.name)));
|
||||
const usedNamesInBatch = new Set();
|
||||
const envsToImport = [];
|
||||
|
||||
filteredEnvironments.forEach((environment) => {
|
||||
const checkExists = (name) => existingGlobalNames.has(name) || usedNamesInBatch.has(name);
|
||||
const uniqueName = generateUniqueName(environment.name, checkExists);
|
||||
|
||||
if (uniqueName !== environment.name) {
|
||||
globalRenamedEnvNames[environment.uid] = uniqueName;
|
||||
}
|
||||
usedNamesInBatch.add(normalizeName(uniqueName));
|
||||
envsToImport.push({ ...environment, name: uniqueName });
|
||||
});
|
||||
|
||||
if (Object.keys(globalRenamedEnvNames).length > 0) {
|
||||
setRenamedEnvironmentNames((prev) => ({ ...prev, ...globalRenamedEnvNames }));
|
||||
}
|
||||
|
||||
envsToImport.forEach((envToImport) => {
|
||||
const originalUid = envToImport.uid;
|
||||
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.LOADING }));
|
||||
|
||||
dispatch(addGlobalEnvironment(envToImport))
|
||||
.then(() => setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.SUCCESS })))
|
||||
.catch((error) => {
|
||||
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.ERROR }));
|
||||
setErrorMessages((prev) => ({ ...prev, [originalUid]: error.message || 'Failed to add environment' }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setImportStarted(true);
|
||||
|
||||
if (filteredCollections.length > 1 || isBulkImport || isMultipleImport) {
|
||||
dispatch(importCollection(filteredCollections, values.collectionLocation, { format: collectionFormat }))
|
||||
.catch((err) => {
|
||||
console.error('Failed to import collections', err);
|
||||
filteredCollections.forEach((collection) => {
|
||||
setStatus((prev) => ({ ...prev, [collection.uid]: STATUS.ERROR }));
|
||||
setErrorMessages((prev) => ({ ...prev, [collection.uid]: err.message || 'Failed to import collection' }));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
handleSubmit(filteredCollections[0], values.collectionLocation, { format: collectionFormat });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const browse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string' && dirPath.length > 0) {
|
||||
formik.setFieldValue('collectionLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('collectionLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const handleImportStatus = (collectionId, status, errorMessage = '') => {
|
||||
setStatus((prev) => ({ ...prev, [collectionId]: status }));
|
||||
if (status === STATUS.ERROR) {
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
[collectionId]: errorMessage
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const importingCollectionStarted = ipcRenderer.on(
|
||||
'main:collection-import-started',
|
||||
(collectionId) => {
|
||||
handleImportStatus(collectionId, STATUS.LOADING);
|
||||
}
|
||||
);
|
||||
const importingCollectionCompleted = ipcRenderer.on(
|
||||
'main:collection-import-ended',
|
||||
(collectionId) => {
|
||||
handleImportStatus(collectionId, STATUS.SUCCESS);
|
||||
}
|
||||
);
|
||||
const importingCollectionFailed = ipcRenderer.on(
|
||||
'main:collection-import-failed',
|
||||
(collectionId, { message }) => {
|
||||
handleImportStatus(collectionId, STATUS.ERROR, message);
|
||||
}
|
||||
);
|
||||
const allCollectionsImportCompleted = ipcRenderer.on(
|
||||
'main:all-collections-import-ended',
|
||||
(report) => {
|
||||
toast.success(report?.message);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
importingCollectionStarted();
|
||||
importingCollectionCompleted();
|
||||
importingCollectionFailed();
|
||||
allCollectionsImportCompleted();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onSubmit = () => {
|
||||
if (importStarted) {
|
||||
onClose();
|
||||
} else {
|
||||
formik.handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleErrorClick = (error, uid) => {
|
||||
setSelectedError({ message: error, uid });
|
||||
setShowErrorModal(true);
|
||||
};
|
||||
|
||||
const ErrorModal = ({ error, onClose }) => (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Error Details"
|
||||
handleConfirm={onClose}
|
||||
handleCancel={onClose}
|
||||
showCancelButton={false}
|
||||
disableCloseOnOutsideClick={true}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div className="p-4">
|
||||
<pre className="whitespace-pre-wrap text-red-600 text-sm">{error}</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Bulk Import"
|
||||
confirmText={importStarted ? 'Close' : 'Import'}
|
||||
confirmDisabled={Boolean(!selectedCollections?.length)}
|
||||
handleConfirm={onSubmit}
|
||||
handleCancel={onClose}
|
||||
showConfirm={true}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={false}
|
||||
hideCancel={importStarted}
|
||||
>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="flex flex-col">
|
||||
{importStarted ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between relative mb-5 w-full">
|
||||
<div className="font-semibold">Location</div>
|
||||
<div className="text-sm border border-slate-600 rounded px-3 py-1.5 ml-4 flex-1">
|
||||
{formik.values.collectionLocation
|
||||
|| 'No location selected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold">
|
||||
Importing Collections ({importStatus.totalSelected})
|
||||
</div>
|
||||
{importStatus.failedCount > 0 && importStatus.totalSelected > 0 && (
|
||||
<div className="text-sm text-red-500">
|
||||
({importStatus.failedCount}/{importStatus.totalSelected} failed)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
|
||||
{sortedCollections
|
||||
.filter((collection) =>
|
||||
selectedCollections.includes(collection.uid)
|
||||
)
|
||||
.map((collection) => (
|
||||
<div
|
||||
key={collection.uid}
|
||||
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
|
||||
>
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="flex items-center mr-2">
|
||||
{status[collection.uid] === STATUS.LOADING && (
|
||||
<IconLoader2
|
||||
className="animate-spin text-blue-500"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
{status[collection.uid] === STATUS.SUCCESS && (
|
||||
<div className="flex items-center text-green-500">
|
||||
<IconCheck size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
{status[collection.uid] === STATUS.ERROR && (
|
||||
<div className="flex items-center">
|
||||
<IconX
|
||||
className="text-red-500"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span>{renamedCollectionNames[collection.uid] || collection.name}</span>
|
||||
</div>
|
||||
{status[collection.uid] === STATUS.ERROR && (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleErrorClick(
|
||||
errorMessages[collection.uid],
|
||||
collection.uid
|
||||
)}
|
||||
className="text-red-500 text-sm hover:underline"
|
||||
>
|
||||
See error
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEnvironments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="font-semibold mb-2">
|
||||
Importing Environments ({selectedEnvironments.length})
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
|
||||
{sortedEnvironments
|
||||
.filter((env) => selectedEnvironments.includes(env.uid))
|
||||
.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
|
||||
>
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="flex items-center mr-2">
|
||||
{!environmentStatus[env.uid] || environmentStatus[env.uid] === STATUS.LOADING ? (
|
||||
<IconLoader2
|
||||
className="animate-spin text-blue-500"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : environmentStatus[env.uid] === STATUS.SUCCESS ? (
|
||||
<div className="flex items-center text-green-500">
|
||||
<IconCheck size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
) : environmentStatus[env.uid] === STATUS.ERROR ? (
|
||||
<div className="flex items-center">
|
||||
<IconX
|
||||
className="text-red-500"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{renamedEnvironmentNames[env.uid] || env.name}</span>
|
||||
</div>
|
||||
{environmentStatus[env.uid] === STATUS.ERROR && (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleErrorClick(
|
||||
errorMessages[env.uid],
|
||||
env.uid
|
||||
)}
|
||||
className="text-red-500 text-sm hover:underline"
|
||||
>
|
||||
See error
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<div className="font-semibold mb-2 flex justify-between items-center">
|
||||
<span>Collections ({importedCollection.length})</span>
|
||||
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allCollectionsSelected}
|
||||
onChange={handleSelectAllCollections}
|
||||
className="mr-2"
|
||||
/>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2">
|
||||
{importedCollection.length === 0 && (
|
||||
<div className="px-4 py-2 text-gray-400 italic">
|
||||
No collections found
|
||||
</div>
|
||||
)}
|
||||
{sortedCollections.map((collection) => (
|
||||
<label
|
||||
key={collection.uid}
|
||||
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer justify-between"
|
||||
>
|
||||
<div className="flex items-center flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCollections.includes(collection.uid)}
|
||||
onChange={() => handleCollectionToggle(collection.uid)}
|
||||
className="mr-3"
|
||||
/>
|
||||
<span>{collection.name}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importType === 'bulk' && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="font-semibold mb-2 flex justify-between items-center">
|
||||
<span>Environments ({importedEnvironment.length})</span>
|
||||
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allEnvironmentsSelected}
|
||||
onChange={handleSelectAllEnvironments}
|
||||
className="mr-2"
|
||||
/>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
|
||||
{importedEnvironment.length === 0 && (
|
||||
<div className="px-4 py-2 text-gray-400 italic">
|
||||
No environments found
|
||||
</div>
|
||||
)}
|
||||
{sortedEnvironments.map((env) => (
|
||||
<label
|
||||
key={env.uid}
|
||||
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnvironments.includes(env.uid)}
|
||||
onChange={() => handleEnvironmentToggle(env.uid)}
|
||||
className="mr-3"
|
||||
/>
|
||||
<span>{env.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="font-semibold mb-2">
|
||||
Environment Assignment
|
||||
</div>
|
||||
<div className="flex gap-8 mt-2 ml-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToGlobal}
|
||||
onChange={(e) => setApplyToGlobal(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Global Environment
|
||||
<InfoTip
|
||||
content="Environments will be imported and stored as global, accessible across collections."
|
||||
infotipId="apply-to-global-infotip"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToCollection}
|
||||
onChange={(e) =>
|
||||
setApplyToCollection(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Duplicate Across Collections
|
||||
<InfoTip
|
||||
content="Each imported collection will receive its own copy of the environments."
|
||||
infotipId="apply-to-each-infotip"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-start flex-col relative">
|
||||
<div className="font-semibold mb-2">Location</div>
|
||||
<input
|
||||
id="collection-location"
|
||||
type="text"
|
||||
placeholder="Select a location to save the collection"
|
||||
name="collectionLocation"
|
||||
className="block textbox w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue('collectionLocation', e.target.value);
|
||||
}}
|
||||
/>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
<div className="text-red-500 mt-1">
|
||||
{formik.errors.collectionLocation}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1">
|
||||
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="format" className="flex items-center font-semibold">
|
||||
File Format
|
||||
<Help width="300">
|
||||
<p>Choose the file format for storing requests in this collection.</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={collectionFormat}
|
||||
onChange={(e) => setCollectionFormat(e.target.value)}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isMultipleImport && hasOpenApiSpec && (
|
||||
<div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div>
|
||||
<label htmlFor="groupingType" className="block font-semibold">
|
||||
Folder arrangement
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 mb-2">
|
||||
Select whether to create folders according to the spec's paths or tags.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
|
||||
{groupingOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="dropdown-item"
|
||||
data-testid={option.testId}
|
||||
onClick={() => {
|
||||
dropdownTippyRef?.current?.hide();
|
||||
setGroupingType(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{showErrorModal && (
|
||||
<ErrorModal
|
||||
error={selectedError?.message}
|
||||
onClose={() => setShowErrorModal(false)}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkImportCollectionLocation;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { normalizeName, generateUniqueName } from './index';
|
||||
|
||||
describe('BulkImportCollectionLocation helpers', () => {
|
||||
describe('normalizeName', () => {
|
||||
it('should trim and lowercase names', () => {
|
||||
expect(normalizeName(' Beta ')).toBe('beta');
|
||||
expect(normalizeName('TEST')).toBe('test');
|
||||
expect(normalizeName(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUniqueName', () => {
|
||||
it('should return original name if no conflict', () => {
|
||||
const checkExists = () => false;
|
||||
expect(generateUniqueName('Beta', checkExists)).toBe('Beta');
|
||||
});
|
||||
|
||||
it('should add "copy" suffix on first conflict', () => {
|
||||
const existing = new Set(['beta']);
|
||||
const checkExists = (name) => existing.has(name);
|
||||
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy');
|
||||
});
|
||||
|
||||
it('should increment copy number on multiple conflicts', () => {
|
||||
const existing = new Set(['beta', 'beta copy']);
|
||||
const checkExists = (name) => existing.has(name);
|
||||
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.info-box {
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
color: ${(props) => props.theme.text};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-top: 5px;
|
||||
width: 400px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import {
|
||||
browseDirectory,
|
||||
cloneGitRepository,
|
||||
openMultipleCollections,
|
||||
scanForBrunoFiles
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
|
||||
import Modal from 'components/Modal';
|
||||
import * as path from 'path';
|
||||
import Portal from 'components/Portal';
|
||||
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
|
||||
import { uuid } from 'utils/common/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getRepoNameFromUrl } from 'utils/git';
|
||||
import GitNotFoundModal from 'components/Git/GitNotFoundModal/index';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null }) => {
|
||||
const [collectionPaths, setCollectionPaths] = useState([]);
|
||||
const [selectedCollectionPaths, setSelectedCollectionPaths] = useState([]);
|
||||
const [processUid, setProcessUid] = useState(uuid());
|
||||
const [steps, setSteps] = useState([]);
|
||||
const [view, setView] = useState('form');
|
||||
|
||||
const progressData = useSelector((state) => state.app.gitOperationProgress[processUid]);
|
||||
const { gitVersion } = useSelector((state) => state.app);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultCollectionLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (progressData) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.step === 'clone' && !step?.completed
|
||||
? { ...step, title: 'Cloning repository', completed: false, info: progressData.progressData }
|
||||
: step
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [progressData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef?.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cloneInProgress = () => {
|
||||
setSteps((prev) => [
|
||||
...prev,
|
||||
{
|
||||
step: 'clone',
|
||||
title: 'Cloning repository',
|
||||
completed: false
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const cloneFinished = () => {
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.step === 'clone'
|
||||
? { ...step, title: 'Cloning successful', completed: true, info: '' }
|
||||
: step
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const cloneError = () => {
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.step === 'clone'
|
||||
? { ...step, title: 'Cloning failed', completed: true, error: true }
|
||||
: step
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const scanInProgress = () => {
|
||||
setSteps((prev) => [
|
||||
...prev,
|
||||
{
|
||||
step: 'scan',
|
||||
title: 'Scanning for Bruno files',
|
||||
completed: false
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const scanFinished = () => {
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.step === 'scan' ? { ...step, title: 'Scan successful', completed: true, info: '' } : step
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
repositoryUrl: collectionRepositoryUrl || '',
|
||||
collectionLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
repositoryUrl: Yup.string().required('Repository URL is required'),
|
||||
collectionLocation: Yup.string().min(1, 'Location is required').required('Location is required')
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
setView('progress');
|
||||
cloneInProgress();
|
||||
const { repositoryUrl, collectionLocation } = values;
|
||||
|
||||
const repoName = getRepoNameFromUrl(repositoryUrl);
|
||||
const targetPath = path.join(collectionLocation, repoName);
|
||||
|
||||
await dispatch(cloneGitRepository({ url: values.repositoryUrl, path: targetPath, processUid }));
|
||||
|
||||
cloneFinished();
|
||||
dispatch(removeGitOperationProgress(processUid));
|
||||
|
||||
scanInProgress();
|
||||
const foundCollectionPaths = await dispatch(scanForBrunoFiles(targetPath));
|
||||
|
||||
scanFinished();
|
||||
setCollectionPaths(foundCollectionPaths);
|
||||
} catch (err) {
|
||||
cloneError();
|
||||
dispatch(removeGitOperationProgress(processUid));
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const browse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('collectionLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('collectionLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCollectionSelect = (collection) => {
|
||||
setSelectedCollectionPaths((prevSelected) =>
|
||||
prevSelected.includes(collection)
|
||||
? prevSelected.filter((c) => c !== collection)
|
||||
: [...prevSelected, collection]
|
||||
);
|
||||
};
|
||||
|
||||
const getRelativePath = (fullPath, pathname) => {
|
||||
let relativePath = path.relative(fullPath, pathname);
|
||||
const { dir, name } = path.parse(relativePath);
|
||||
return path.join(dir, name);
|
||||
};
|
||||
|
||||
const isScanCompleted = () => steps.some((step) => step.step === 'scan' && step.completed);
|
||||
|
||||
const isConfirmDisabled = () => isScanCompleted() && collectionPaths?.length > 0 && selectedCollectionPaths?.length === 0;
|
||||
|
||||
const isFooterHidden = () => steps.some((step) => !step.completed);
|
||||
|
||||
const isError = () => steps.some((step) => step.error);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const buttonText = getConfirmText();
|
||||
switch (buttonText) {
|
||||
case 'Clone':
|
||||
formik.handleSubmit();
|
||||
break;
|
||||
case 'Close':
|
||||
onClose();
|
||||
break;
|
||||
case 'Open':
|
||||
if (collectionPaths.length > 0 && selectedCollectionPaths.length > 0) {
|
||||
dispatch(openMultipleCollections(selectedCollectionPaths));
|
||||
onClose();
|
||||
onFinish();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfirmText = () =>
|
||||
!steps.length
|
||||
? 'Clone'
|
||||
: steps.some((step) => !step.completed || step.error || (isScanCompleted() && !collectionPaths?.length))
|
||||
? 'Close'
|
||||
: 'Open';
|
||||
|
||||
const handleBackButtonClick = () => {
|
||||
setView('form');
|
||||
setSteps([]);
|
||||
setSelectedCollectionPaths([]);
|
||||
};
|
||||
|
||||
if (!gitVersion) {
|
||||
return <GitNotFoundModal onClose={onClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal id="clone-repository-portal">
|
||||
<Modal
|
||||
size="md"
|
||||
title="Clone Git Repository"
|
||||
confirmText={getConfirmText()}
|
||||
handleConfirm={handleConfirm}
|
||||
handleCancel={onClose}
|
||||
confirmDisabled={isConfirmDisabled()}
|
||||
hideFooter={isFooterHidden()}
|
||||
hideCancel={isError() || (isScanCompleted() && !collectionPaths?.length)}
|
||||
showBackButton={isError()}
|
||||
handleBack={handleBackButtonClick}
|
||||
>
|
||||
<StyledWrapper>
|
||||
{view === 'form' && (
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
{collectionRepositoryUrl
|
||||
? (
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<IconBrandGit className="w-6 h-6 text-purple-500" stroke={1.5} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-sm">{getRepoNameFromUrl(collectionRepositoryUrl)}</div>
|
||||
<div className="mt-1 text-xs text-muted font-mono">
|
||||
{collectionRepositoryUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<label htmlFor="repository-url" className="flex items-center font-semibold">
|
||||
Git Repository URL
|
||||
</label>
|
||||
<input
|
||||
id="repository-url"
|
||||
type="text"
|
||||
name="repositoryUrl"
|
||||
ref={inputRef}
|
||||
className="block textbox mt-2 w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.repositoryUrl || ''}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{formik.touched.repositoryUrl && formik.errors.repositoryUrl && (
|
||||
<div className="text-red-500">{formik.errors.repositoryUrl}</div>
|
||||
)}
|
||||
<label htmlFor="collection-location" className="block font-semibold mt-3">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
readOnly
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
/>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation && (
|
||||
<div className="text-red-500">{formik.errors.collectionLocation}</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{view === 'progress' && (
|
||||
<>
|
||||
{steps.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
{steps.map((step, index) => (
|
||||
<li key={index} className="flex-col items-center space-x-2 mt-1">
|
||||
<div className="flex">
|
||||
{step.error ? (
|
||||
<IconAlertCircle className="text-red-500" size={18} strokeWidth={1.5} />
|
||||
) : (
|
||||
<>
|
||||
{step.completed ? (
|
||||
<IconCheck className="text-green-500" size={18} strokeWidth={1.5} />
|
||||
) : (
|
||||
<IconRefresh className="text-yellow-500 animate-spin" size={18} strokeWidth={1.5} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className="ml-2">{step.title}</span>
|
||||
</div>
|
||||
{step.info && (
|
||||
<div className="w-full mt-2">
|
||||
<pre className="info-box ml-4">{step.info}</pre>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{isScanCompleted() && (
|
||||
<div className="mt-4 mb-4">
|
||||
{collectionPaths.length === 0 && (
|
||||
<div className="flex">
|
||||
<IconAlertCircle className="text-yellow-500" size={18} strokeWidth={1.5} />
|
||||
<h3 className="text-sm ml-2">No bruno collections found in this repository.</h3>
|
||||
</div>
|
||||
)}
|
||||
{collectionPaths.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-sm mb-2">
|
||||
{collectionPaths.length} bruno collections found. Please select the collections to open:
|
||||
</h3>
|
||||
<ul>
|
||||
{collectionPaths.map((collection) => (
|
||||
<li key={collection} className="mb-2">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCollectionPaths.includes(collection)}
|
||||
onChange={() => handleCollectionSelect(collection)}
|
||||
className="form-checkbox"
|
||||
/>
|
||||
<span>{getRelativePath(formik.values.collectionLocation, collection)}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloneGitRepository;
|
||||
@@ -92,6 +92,53 @@ const FileTab = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMultipleFiles = async (fileArray) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const filesData = [];
|
||||
|
||||
// Parse all files
|
||||
for (const file of fileArray) {
|
||||
try {
|
||||
const data = await convertFileToObject(file);
|
||||
|
||||
// Determine type for each file
|
||||
let type = null;
|
||||
if (isOpenApiSpec(data)) {
|
||||
type = 'openapi';
|
||||
} else if (isWSDLCollection(data)) {
|
||||
type = 'wsdl';
|
||||
} else if (isPostmanCollection(data)) {
|
||||
type = 'postman';
|
||||
} else if (isInsomniaCollection(data)) {
|
||||
type = 'insomnia';
|
||||
} else if (isOpenCollection(data)) {
|
||||
type = 'opencollection';
|
||||
} else if (isBrunoCollection(data)) {
|
||||
type = 'bruno';
|
||||
}
|
||||
|
||||
if (type) {
|
||||
filesData.push({ file, data, type });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to process file ${file.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesData.length > 0) {
|
||||
// Pass raw filesData to be processed in BulkImportCollectionLocation
|
||||
handleSubmit({ filesData, type: 'multiple' });
|
||||
} else {
|
||||
throw new Error('No valid collections found in the selected files');
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err, 'Import multiple files failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async (file) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -149,7 +196,10 @@ const FileTab = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileArray.length > 0) {
|
||||
if (fileArray.length > 1) {
|
||||
// Process multiple non-ZIP files normally
|
||||
await handleMultipleFiles(fileArray);
|
||||
} else if (fileArray.length === 1) {
|
||||
await processFile(fileArray[0]);
|
||||
}
|
||||
};
|
||||
@@ -200,17 +250,18 @@ const FileTab = ({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFileInputChange}
|
||||
accept={acceptedFileTypes.join(',')}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
Drop file to import or{' '}
|
||||
Drop file(s) to import or{' '}
|
||||
<button
|
||||
className="underline cursor-pointer"
|
||||
onClick={handleBrowseFiles}
|
||||
style={{ color: theme.textLink }}
|
||||
>
|
||||
choose a file
|
||||
choose file(s)
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import { isGitRepositoryUrl } from 'utils/git';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
const GitHubTab = ({
|
||||
handleSubmit,
|
||||
setErrorMessage
|
||||
}) => {
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
|
||||
const handleGitRepositoryImport = (url) => {
|
||||
if (!isGitRepositoryUrl(url)) {
|
||||
setErrorMessage('Please enter a valid git repository URL');
|
||||
return;
|
||||
}
|
||||
handleSubmit({ repositoryUrl: url, type: 'git-repository' });
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (urlInput.trim()) {
|
||||
handleGitRepositoryImport(urlInput.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="gitUrlInput"
|
||||
data-testid="git-url-input"
|
||||
type="text"
|
||||
value={urlInput}
|
||||
autoFocus
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder="Enter Git repository URL"
|
||||
className="flex-1 px-3 py-1 textbox"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
id="clone-git-button"
|
||||
disabled={!urlInput.trim()}
|
||||
variant="filled"
|
||||
color="primary"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubTab;
|
||||
@@ -0,0 +1,30 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.tabs {
|
||||
.tab {
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
&:focus-within,
|
||||
&:focus-visible,
|
||||
&:target {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import Button from 'ui/Button';
|
||||
const UrlTab = ({
|
||||
setIsLoading,
|
||||
handleSubmit,
|
||||
setErrorMessage
|
||||
}) => {
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
|
||||
const handleUrlImport = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!urlInput.trim() || !isValidUrl(urlInput.trim())) {
|
||||
setErrorMessage('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { data, specType } = await fetchAndValidateApiSpecFromUrl({ url: urlInput.trim() });
|
||||
// Pass raw data for all types
|
||||
handleSubmit({ rawData: data, type: specType });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setErrorMessage('URL import failed. Please check the URL and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleUrlImport}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="urlInput"
|
||||
data-testid="url-input"
|
||||
type="text"
|
||||
value={urlInput}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setUrlInput(e.target.value);
|
||||
setErrorMessage('');
|
||||
}}
|
||||
placeholder="Enter URL (OpenAPI/Swagger, Postman, or Insomnia specification)"
|
||||
className="flex-1 px-3 py-1 textbox"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
id="import-url-button"
|
||||
disabled={!urlInput.trim()}
|
||||
variant="filled"
|
||||
color="primary"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlTab;
|
||||
@@ -1,41 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconX } from '@tabler/icons';
|
||||
import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import classnames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FileTab from './FileTab';
|
||||
import GitHubTab from './GitHubTab';
|
||||
import UrlTab from './UrlTab';
|
||||
import FullscreenLoader from './FullscreenLoader/index';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const IMPORT_TABS = {
|
||||
FILE: 'file',
|
||||
GITHUB: 'github',
|
||||
URL: 'url'
|
||||
};
|
||||
|
||||
const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
const { theme } = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [tab, setTab] = useState(IMPORT_TABS.FILE);
|
||||
|
||||
const handleTabSelect = (value) => () => {
|
||||
setTab(value);
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
const getTabClassname = (tabName) => {
|
||||
return classnames(`flex tab items-center py-2 px-4 ${tabName}`, {
|
||||
active: tabName === tab
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <FullscreenLoader isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
|
||||
<div className="flex flex-col">
|
||||
<Modal size="md" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
|
||||
<StyledWrapper className="flex flex-col h-full w-[600px] max-w-[600px]">
|
||||
<div className="flex w-full mb-6">
|
||||
<div className="flex justify-start w-full tabs">
|
||||
<div
|
||||
className={getTabClassname(IMPORT_TABS.FILE)}
|
||||
onClick={handleTabSelect(IMPORT_TABS.FILE)}
|
||||
data-testid="file-tab"
|
||||
>
|
||||
<IconFileImport size={18} strokeWidth={1.5} className="mr-2" />
|
||||
File
|
||||
</div>
|
||||
<div
|
||||
className={getTabClassname(IMPORT_TABS.GITHUB)}
|
||||
onClick={handleTabSelect(IMPORT_TABS.GITHUB)}
|
||||
data-testid="github-tab"
|
||||
>
|
||||
<IconBrandGit size={18} strokeWidth={1.5} className="mr-2" />
|
||||
Git Repository
|
||||
</div>
|
||||
<div
|
||||
className={getTabClassname(IMPORT_TABS.URL)}
|
||||
onClick={handleTabSelect(IMPORT_TABS.URL)}
|
||||
data-testid="url-tab"
|
||||
>
|
||||
<IconUnlink size={18} strokeWidth={1.5} className="mr-2" />
|
||||
URL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div
|
||||
className="mb-4 p-2 border rounded-md"
|
||||
style={{
|
||||
backgroundColor: theme.status?.danger?.background || '#fef2f2',
|
||||
borderColor: theme.status?.danger?.border || '#fecaca'
|
||||
backgroundColor: theme.status.danger.background,
|
||||
borderColor: theme.status.danger.border
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="text-xs flex-1"
|
||||
style={{ color: theme.status?.danger?.text || '#dc2626' }}
|
||||
style={{ color: theme.status.danger.text }}
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
<div
|
||||
className="close-button flex items-center cursor-pointer"
|
||||
onClick={() => setErrorMessage('')}
|
||||
style={{ color: theme.status?.danger?.text || '#dc2626' }}
|
||||
style={{ color: theme.status.danger.text }}
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
@@ -43,12 +94,27 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileTab
|
||||
setIsLoading={setIsLoading}
|
||||
handleSubmit={handleSubmit}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
</div>
|
||||
{tab === IMPORT_TABS.FILE && (
|
||||
<FileTab
|
||||
setIsLoading={setIsLoading}
|
||||
handleSubmit={handleSubmit}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
{tab === IMPORT_TABS.GITHUB && (
|
||||
<GitHubTab
|
||||
handleSubmit={handleSubmit}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
{tab === IMPORT_TABS.URL && (
|
||||
<UrlTab
|
||||
setIsLoading={setIsLoading}
|
||||
handleSubmit={handleSubmit}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@ import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
|
||||
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
|
||||
import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import Collections from 'components/Sidebar/Collections';
|
||||
@@ -45,6 +47,8 @@ const CollectionsSection = () => {
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
|
||||
const workspaceCollections = useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
@@ -57,8 +61,15 @@ const CollectionsSection = () => {
|
||||
});
|
||||
}, [activeWorkspace, collections, workspaces]);
|
||||
|
||||
const handleImportCollection = ({ rawData, type, ...rest }) => {
|
||||
const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
|
||||
if (type === 'git-repository') {
|
||||
setGitRepositoryUrl(repositoryUrl);
|
||||
setShowCloneGitModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setImportData({ rawData, type, ...rest });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
@@ -72,14 +83,14 @@ const CollectionsSection = () => {
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseGitModal = () => {
|
||||
setShowCloneGitModal(false);
|
||||
setGitRepositoryUrl(null);
|
||||
};
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
setShowSearch((prev) => !prev);
|
||||
};
|
||||
@@ -250,7 +261,7 @@ const CollectionsSection = () => {
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
@@ -258,6 +269,20 @@ const CollectionsSection = () => {
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && (
|
||||
<BulkImportCollectionLocation
|
||||
importData={importData}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
{showCloneGitModal && (
|
||||
<CloneGitRepository
|
||||
onClose={handleCloseGitModal}
|
||||
onFinish={handleCloseGitModal}
|
||||
collectionRepositoryUrl={gitRepositoryUrl}
|
||||
/>
|
||||
)}
|
||||
<SidebarSection
|
||||
id="collections"
|
||||
title="Collections"
|
||||
|
||||
@@ -6,6 +6,8 @@ import toast from 'react-hot-toast';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
|
||||
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
|
||||
import Button from 'ui/Button';
|
||||
import CollectionsList from './CollectionsList';
|
||||
import WorkspaceDocs from '../WorkspaceDocs';
|
||||
@@ -19,6 +21,8 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const [importData, setImportData] = useState(null);
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
|
||||
const workspaceCollectionsCount = workspace?.collections?.length || 0;
|
||||
|
||||
@@ -51,8 +55,15 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
setImportCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionSubmit = ({ rawData, type, ...rest }) => {
|
||||
const handleImportCollectionSubmit = ({ rawData, type, repositoryUrl, ...rest }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
|
||||
if (type === 'git-repository') {
|
||||
setGitRepositoryUrl(repositoryUrl);
|
||||
setShowCloneGitModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setImportData({ rawData, type, ...rest });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
@@ -66,14 +77,14 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseGitModal = () => {
|
||||
setShowCloneGitModal(false);
|
||||
setGitRepositoryUrl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{createCollectionModalOpen && (
|
||||
@@ -87,7 +98,7 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
@@ -95,6 +106,20 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && (
|
||||
<BulkImportCollectionLocation
|
||||
importData={importData}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
{showCloneGitModal && (
|
||||
<CloneGitRepository
|
||||
onClose={handleCloseGitModal}
|
||||
onFinish={handleCloseGitModal}
|
||||
collectionRepositoryUrl={gitRepositoryUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overview-layout">
|
||||
<div className="overview-main">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
updateCookies,
|
||||
updatePreferences
|
||||
updatePreferences,
|
||||
setGitVersion
|
||||
} from 'providers/ReduxStore/slices/app';
|
||||
import {
|
||||
addTab
|
||||
@@ -329,6 +330,10 @@ const useIpcEvents = () => {
|
||||
dispatch(updateCollectionLoadingState(val));
|
||||
});
|
||||
|
||||
const gitVersionListener = ipcRenderer.on('main:git-version', (val) => {
|
||||
dispatch(setGitVersion(val));
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeCollectionTreeUpdateListener();
|
||||
removeApiSpecTreeUpdateListener();
|
||||
@@ -360,6 +365,7 @@ const useIpcEvents = () => {
|
||||
removeCollectionLoadingStateListener();
|
||||
removePersistentEnvVariablesUpdateListener();
|
||||
removeSystemResourcesListener();
|
||||
gitVersionListener();
|
||||
};
|
||||
}, [isElectron]);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,8 @@ const initialState = {
|
||||
},
|
||||
cookies: [],
|
||||
taskQueue: [],
|
||||
gitOperationProgress: {},
|
||||
gitVersion: null,
|
||||
clipboard: {
|
||||
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
|
||||
},
|
||||
@@ -123,6 +125,19 @@ export const appSlice = createSlice({
|
||||
toggleSidebarCollapse: (state) => {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
},
|
||||
updateGitOperationProgress: (state, action) => {
|
||||
const { uid, data } = action.payload;
|
||||
if (!state.gitOperationProgress[uid]) {
|
||||
state.gitOperationProgress[uid] = { progressData: [] };
|
||||
}
|
||||
state.gitOperationProgress[uid].progressData.push(data);
|
||||
},
|
||||
removeGitOperationProgress: (state, action) => {
|
||||
delete state.gitOperationProgress[action.payload];
|
||||
},
|
||||
setGitVersion: (state, action) => {
|
||||
state.gitVersion = action.payload;
|
||||
},
|
||||
setClipboard: (state, action) => {
|
||||
// Update clipboard UI state
|
||||
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
|
||||
@@ -164,6 +179,9 @@ export const {
|
||||
updateSystemProxyVariables,
|
||||
updateGenerateCode,
|
||||
toggleSidebarCollapse,
|
||||
updateGitOperationProgress,
|
||||
removeGitOperationProgress,
|
||||
setGitVersion,
|
||||
setClipboard
|
||||
} = appSlice.actions;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import trim from 'lodash/trim';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import IpcErrorModal from 'components/Errors/IpcErrorModal/index';
|
||||
import {
|
||||
findCollectionByUid,
|
||||
findEnvironmentInCollection,
|
||||
@@ -2685,19 +2686,22 @@ export const importCollection = (collection, collectionLocation, options = {}) =
|
||||
try {
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
const isMultiple = Array.isArray(collection);
|
||||
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
|
||||
const result = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
|
||||
const importedPaths = result.success.items;
|
||||
|
||||
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
const workspaceCollection = {
|
||||
name: collection.name,
|
||||
path: collectionPath
|
||||
};
|
||||
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
|
||||
if (importedPaths.length > 0 && activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
for (const importedItem of importedPaths) {
|
||||
const workspaceCollection = {
|
||||
name: importedItem.name,
|
||||
path: importedItem.path
|
||||
};
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(collectionPath);
|
||||
resolve(isMultiple ? importedPaths : importedPaths[0]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
@@ -3027,6 +3031,34 @@ export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch,
|
||||
});
|
||||
};
|
||||
|
||||
export const cloneGitRepository = (data) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:clone-git-repository', data)
|
||||
.then((res) => {
|
||||
console.log('clone done', res);
|
||||
})
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.custom(<IpcErrorModal error={err?.message} />);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const scanForBrunoFiles = (dir) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:scan-for-bruno-files', dir)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
reject();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Close tabs and delete any transient request files from the filesystem.
|
||||
* This thunk wraps the closeTabs reducer to handle transient file cleanup automatically.
|
||||
|
||||
63
packages/bruno-app/src/utils/git/index.js
Normal file
63
packages/bruno-app/src/utils/git/index.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import gitUrlParse from 'git-url-parse';
|
||||
|
||||
const isGitUrl = (str) => {
|
||||
try {
|
||||
const parsed = gitUrlParse(str);
|
||||
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate that it has the essential parts of a git URL and uses valid protocols
|
||||
const validProtocols = ['git', 'ssh', 'http', 'https'];
|
||||
return !!(
|
||||
parsed
|
||||
&& parsed.owner
|
||||
&& parsed.source
|
||||
&& validProtocols.includes(parsed.protocol)
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRepoNameFromUrl = (url) => {
|
||||
try {
|
||||
const parsedUrl = gitUrlParse(url);
|
||||
return parsedUrl.name;
|
||||
} catch (error) {
|
||||
throw new Error('Invalid Git URL');
|
||||
}
|
||||
};
|
||||
|
||||
export const containsGitHubToken = (remoteUrl) => {
|
||||
const GITHUB_TOKEN_REGEX = /(ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_]{30,}/;
|
||||
return GITHUB_TOKEN_REGEX.test(remoteUrl);
|
||||
};
|
||||
|
||||
export const getSafeGitRemoteUrls = (remotes = []) => {
|
||||
const remoteUrls = remotes
|
||||
?.map((remote) => remote?.refs?.fetch)
|
||||
?.filter((url) => typeof url === 'string' && url?.trim()?.length > 0);
|
||||
|
||||
const safeRemoteUrls = remoteUrls
|
||||
?.filter((remoteUrl) => !containsGitHubToken(remoteUrl));
|
||||
return safeRemoteUrls || [];
|
||||
};
|
||||
|
||||
export const isGitRepositoryUrl = (url) => {
|
||||
try {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First try the URL as-is
|
||||
if (isGitUrl(url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
112
packages/bruno-app/src/utils/git/index.spec.js
Normal file
112
packages/bruno-app/src/utils/git/index.spec.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { containsGitHubToken, getSafeGitRemoteUrls, isGitRepositoryUrl } from './index';
|
||||
|
||||
describe('containsGitHubToken', () => {
|
||||
test('should return true for a URL containing a GitHub token', () => {
|
||||
expect(containsGitHubToken('https://ghp_abcdefgh1234567890abcdefgh12345678@github.com'))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for a URL without a GitHub token', () => {
|
||||
expect(containsGitHubToken('https://github.com/user/repo.git'))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for an empty string', () => {
|
||||
expect(containsGitHubToken(''))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for a null value', () => {
|
||||
expect(containsGitHubToken(null))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for a URL with a similar but invalid token', () => {
|
||||
expect(containsGitHubToken('https://ghz_abcdefgh1234567890@github.com'))
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSafeGitRemoteUrls', () => {
|
||||
test('should filter out URLs containing GitHub tokens', () => {
|
||||
const remotes = [
|
||||
{ refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } },
|
||||
{ refs: { fetch: 'https://github.com/user/repo.git' } },
|
||||
{ refs: { fetch: 'git@github.com:user/repo.git' } }
|
||||
];
|
||||
expect(getSafeGitRemoteUrls(remotes)).toEqual([
|
||||
'https://github.com/user/repo.git',
|
||||
'git@github.com:user/repo.git'
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return an empty array if all URLs contain GitHub tokens', () => {
|
||||
const remotes = [
|
||||
{ refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } },
|
||||
{ refs: { fetch: 'https://gho_abcdefgh1234567890abcdefgh12345678@github.com' } }
|
||||
];
|
||||
expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return an empty array if no valid URLs are present', () => {
|
||||
const remotes = [
|
||||
{ refs: { fetch: '' } },
|
||||
{ refs: { fetch: null } },
|
||||
{ refs: { fetch: undefined } }
|
||||
];
|
||||
expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return an empty array if input is null or undefined', () => {
|
||||
expect(getSafeGitRemoteUrls(null)).toEqual([]);
|
||||
expect(getSafeGitRemoteUrls(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test('should ignore remotes with no fetch property', () => {
|
||||
const remotes = [
|
||||
{ refs: {} },
|
||||
{}
|
||||
];
|
||||
expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGitRepositoryUrl', () => {
|
||||
test('should return true for valid HTTPS GitHub URLs', () => {
|
||||
expect(isGitRepositoryUrl('https://github.com/user/repo.git')).toBe(true);
|
||||
expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true); // automatically adds .git suffix
|
||||
});
|
||||
|
||||
test('should return true for valid SSH GitHub URLs', () => {
|
||||
expect(isGitRepositoryUrl('git@github.com:user/repo.git')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for custom Git server URLs', () => {
|
||||
expect(isGitRepositoryUrl('https://git.example.com/user/repo.git')).toBe(true);
|
||||
expect(isGitRepositoryUrl('git@git.example.com:user/repo.git')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for invalid URLs', () => {
|
||||
expect(isGitRepositoryUrl('')).toBe(false);
|
||||
expect(isGitRepositoryUrl('not-a-url')).toBe(false);
|
||||
expect(isGitRepositoryUrl('https://example.com')).toBe(false);
|
||||
expect(isGitRepositoryUrl('ftp://github.com/user/repo.git')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return true for HTTPS URLs without .git suffix for valid Git hosts', () => {
|
||||
expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true);
|
||||
expect(isGitRepositoryUrl('https://gitlab.com/user/repo')).toBe(true);
|
||||
expect(isGitRepositoryUrl('https://bitbucket.org/user/repo')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for null or undefined', () => {
|
||||
expect(isGitRepositoryUrl(null)).toBe(false);
|
||||
expect(isGitRepositoryUrl(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle malformed URLs gracefully', () => {
|
||||
expect(isGitRepositoryUrl('https://')).toBe(false);
|
||||
expect(isGitRepositoryUrl('git@')).toBe(false);
|
||||
expect(isGitRepositoryUrl('://invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,31 @@
|
||||
import jsyaml from 'js-yaml';
|
||||
import each from 'lodash/each';
|
||||
import get from 'lodash/get';
|
||||
import filter from 'lodash/filter';
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { uuid } from 'utils/common';
|
||||
import { isItemARequest } from 'utils/collections';
|
||||
import { collectionSchema } from '@usebruno/schema';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { isOpenApiSpec } from './openapi-collection';
|
||||
import { isPostmanCollection } from './postman-collection';
|
||||
import { isInsomniaCollection } from './insomnia-collection';
|
||||
|
||||
export const validateSchema = (collection = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
collectionSchema
|
||||
.validate(collection)
|
||||
.then(() => resolve(collection))
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
reject(new BrunoError('The Collection file is corrupted'));
|
||||
});
|
||||
});
|
||||
export const validateSchema = async (collections = []) => {
|
||||
collections = Array.isArray(collections) ? collections : [collections];
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
collections.map(async (collection) => {
|
||||
await collectionSchema.validate(collection);
|
||||
})
|
||||
);
|
||||
return collections;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new BrunoError('The Collection file is corrupted');
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUidsInCollection = (_collection) => {
|
||||
@@ -66,6 +75,18 @@ export const updateUidsInCollection = (_collection) => {
|
||||
return collection;
|
||||
};
|
||||
|
||||
export const filterItemsInCollection = (collection) => {
|
||||
// this filters out the bruno.json item in older collection exports
|
||||
collection.items = filter(collection.items, (item) => {
|
||||
if (item?.name === 'bruno' && item?.type === 'json') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return collection;
|
||||
};
|
||||
|
||||
// todo
|
||||
// need to eventually get rid of supporting old collection app models
|
||||
// 1. start with making request type a constant fetched from a single place
|
||||
@@ -156,7 +177,12 @@ export const transformItemsInCollection = (collection) => {
|
||||
});
|
||||
};
|
||||
|
||||
transformItems(collection.items);
|
||||
if (Array.isArray(collection)) {
|
||||
collection.forEach((col) => transformItems(col.items));
|
||||
} else {
|
||||
transformItems(collection.items);
|
||||
}
|
||||
|
||||
return collection;
|
||||
};
|
||||
|
||||
@@ -173,7 +199,38 @@ export const hydrateSeqInCollection = (collection) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
hydrateSeq(collection.items);
|
||||
|
||||
if (Array.isArray(collection)) {
|
||||
collection.forEach((col) => hydrateSeq(col.items));
|
||||
} else {
|
||||
hydrateSeq(collection.items);
|
||||
}
|
||||
|
||||
return collection;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the schema type(postman, insomnia, openapi) of the CollectionJSON data
|
||||
* @param {Object} data - The JSON data to get the type of
|
||||
* @returns {'openapi' | 'postman' | 'insomnia' | 'unknown'} - The type of the CollectionJSON data
|
||||
*/
|
||||
const getCollectionSpecType = (data) => {
|
||||
return isOpenApiSpec(data) ? 'openapi' : isPostmanCollection(data) ? 'postman' : isInsomniaCollection(data) ? 'insomnia' : 'unknown';
|
||||
};
|
||||
|
||||
export const fetchAndValidateApiSpecFromUrl = ({ url }) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:fetch-api-spec', url)
|
||||
.then((res) => jsyaml.load(res))
|
||||
.then((data) => {
|
||||
const specType = getCollectionSpecType(data);
|
||||
resolve({ data, specType: specType });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
reject(new BrunoError('Failed to fetch API specification: ' + err.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.8",
|
||||
"qs": "^6.14.1",
|
||||
"simple-git": "^3.22.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
|
||||
@@ -41,6 +41,7 @@ const registerPreferencesIpc = require('./ipc/preferences');
|
||||
const registerSystemMonitorIpc = require('./ipc/system-monitor');
|
||||
const registerWorkspaceIpc = require('./ipc/workspace');
|
||||
const registerApiSpecIpc = require('./ipc/apiSpec');
|
||||
const registerGitIpc = require('./ipc/git');
|
||||
const collectionWatcher = require('./app/collection-watcher');
|
||||
const WorkspaceWatcher = require('./app/workspace-watcher');
|
||||
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
|
||||
@@ -403,6 +404,7 @@ app.on('ready', async () => {
|
||||
registerNotificationsIpc(mainWindow, collectionWatcher);
|
||||
registerFilesystemIpc(mainWindow);
|
||||
registerSystemMonitorIpc(mainWindow, systemMonitor);
|
||||
registerGitIpc(mainWindow);
|
||||
});
|
||||
|
||||
// Quit the app once all windows are closed
|
||||
|
||||
@@ -53,7 +53,8 @@ const {
|
||||
isValidDotEnvFilename,
|
||||
isBrunoConfigFile,
|
||||
isBruEnvironmentConfig,
|
||||
isCollectionRootBruFile
|
||||
isCollectionRootBruFile,
|
||||
scanForBrunoFiles
|
||||
} = require('../utils/filesystem');
|
||||
const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
|
||||
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
|
||||
@@ -72,6 +73,7 @@ const collectionWatcher = require('../app/collection-watcher');
|
||||
const { transformBrunoConfigBeforeSave } = require('../utils/transformBrunoConfig');
|
||||
const { REQUEST_TYPES } = require('../utils/constants');
|
||||
const { cancelOAuth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } = require('../utils/oauth2-protocol-handler');
|
||||
const { findUniqueFolderName } = require('../utils/collection-import');
|
||||
|
||||
const environmentSecretsStore = new EnvironmentSecretsStore();
|
||||
const collectionSecurityStore = new CollectionSecurityStore();
|
||||
@@ -1106,122 +1108,171 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, format = DEFAULT_COLLECTION_FORMAT) => {
|
||||
try {
|
||||
let collectionName = sanitizeName(collection.name);
|
||||
let collectionPath = path.join(collectionLocation, collectionName);
|
||||
let collections = Array.isArray(collection) ? collection : [collection];
|
||||
let completedImports = 0;
|
||||
let failedImports = 0;
|
||||
let successfulImports = [];
|
||||
|
||||
if (fs.existsSync(collectionPath)) {
|
||||
throw new Error(`collection: ${collectionPath} already exists`);
|
||||
}
|
||||
for (let coll of collections) {
|
||||
try {
|
||||
// Sending a "started" and "ended" event to renderer to start and stop the spinner.
|
||||
mainWindow.webContents.send('main:collection-import-started', coll.uid);
|
||||
|
||||
const getFilenameWithFormat = (item, format) => {
|
||||
if (item?.filename) {
|
||||
const ext = path.extname(item.filename);
|
||||
if (ext === '.bru' || ext === '.yml') {
|
||||
return item.filename.replace(ext, `.${format}`);
|
||||
}
|
||||
return item.filename;
|
||||
let collectionName = sanitizeName(coll.name);
|
||||
let collectionPath = path.join(collectionLocation, collectionName);
|
||||
|
||||
// Auto-rename if collection already exists
|
||||
if (fs.existsSync(collectionPath)) {
|
||||
const uniqueName = await findUniqueFolderName(coll.name, collectionLocation);
|
||||
collectionName = sanitizeName(uniqueName);
|
||||
collectionPath = path.join(collectionLocation, collectionName);
|
||||
coll.name = uniqueName;
|
||||
}
|
||||
return `${item.name}.${format}`;
|
||||
};
|
||||
|
||||
// Recursive function to parse the collection items and create files/folders
|
||||
const parseCollectionItems = async (items = [], currentPath) => {
|
||||
await Promise.all(items.map(async (item) => {
|
||||
if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
|
||||
let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format));
|
||||
const content = await stringifyRequestViaWorker(item, { format });
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
const getFilenameWithFormat = (item, format) => {
|
||||
if (item?.filename) {
|
||||
const ext = path.extname(item.filename);
|
||||
if (ext === '.bru' || ext === '.yml') {
|
||||
return item.filename.replace(ext, `.${format}`);
|
||||
}
|
||||
return item.filename;
|
||||
}
|
||||
return `${item.name}.${format}`;
|
||||
};
|
||||
|
||||
// Recursive function to parse the collection items and create files/folders
|
||||
const parseCollectionItems = async (items = [], currentPath) => {
|
||||
await Promise.all(items.map(async (item) => {
|
||||
if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
|
||||
let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format));
|
||||
const content = await stringifyRequestViaWorker(item, { format });
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
}
|
||||
if (item.type === 'folder') {
|
||||
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
|
||||
const folderPath = path.join(currentPath, sanitizedFolderName);
|
||||
fs.mkdirSync(folderPath);
|
||||
|
||||
if (item?.root?.meta?.name) {
|
||||
const folderFilePath = path.join(folderPath, `folder.${format}`);
|
||||
item.root.meta.seq = item.seq;
|
||||
const folderContent = await stringifyFolder(item.root, { format });
|
||||
safeWriteFileSync(folderFilePath, folderContent);
|
||||
}
|
||||
|
||||
if (item.items && item.items.length) {
|
||||
await parseCollectionItems(item.items, folderPath);
|
||||
}
|
||||
}
|
||||
// Handle items of type 'js'
|
||||
if (item.type === 'js') {
|
||||
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, item.fileContent);
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const parseEnvironments = async (environments = [], collectionPath) => {
|
||||
const envDirPath = path.join(collectionPath, 'environments');
|
||||
if (!fs.existsSync(envDirPath)) {
|
||||
fs.mkdirSync(envDirPath);
|
||||
}
|
||||
|
||||
await Promise.all(environments.map(async (env) => {
|
||||
const content = await stringifyEnvironment(env, { format });
|
||||
let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`);
|
||||
const filePath = path.join(envDirPath, sanitizedEnvFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
}
|
||||
if (item.type === 'folder') {
|
||||
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
|
||||
const folderPath = path.join(currentPath, sanitizedFolderName);
|
||||
fs.mkdirSync(folderPath);
|
||||
}));
|
||||
};
|
||||
|
||||
if (item?.root?.meta?.name) {
|
||||
const folderFilePath = path.join(folderPath, `folder.${format}`);
|
||||
item.root.meta.seq = item.seq;
|
||||
const folderContent = await stringifyFolder(item.root, { format });
|
||||
safeWriteFileSync(folderFilePath, folderContent);
|
||||
}
|
||||
const getBrunoJsonConfig = (collection) => {
|
||||
let brunoConfig = collection.brunoConfig;
|
||||
|
||||
if (item.items && item.items.length) {
|
||||
await parseCollectionItems(item.items, folderPath);
|
||||
}
|
||||
if (!brunoConfig) {
|
||||
brunoConfig = {
|
||||
version: '1',
|
||||
name: collection.name,
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
}
|
||||
// Handle items of type 'js'
|
||||
if (item.type === 'js') {
|
||||
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, item.fileContent);
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const parseEnvironments = async (environments = [], collectionPath) => {
|
||||
const envDirPath = path.join(collectionPath, 'environments');
|
||||
if (!fs.existsSync(envDirPath)) {
|
||||
fs.mkdirSync(envDirPath);
|
||||
return brunoConfig;
|
||||
};
|
||||
|
||||
await createDirectory(collectionPath);
|
||||
|
||||
const uid = generateUidBasedOnHash(collectionPath);
|
||||
const brunoConfig = getBrunoJsonConfig(coll);
|
||||
|
||||
if (format === 'yml') {
|
||||
brunoConfig.opencollection = '1.0.0';
|
||||
const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format });
|
||||
await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent);
|
||||
} else if (format === 'bru') {
|
||||
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
|
||||
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
|
||||
|
||||
const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format });
|
||||
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
|
||||
} else {
|
||||
throw new Error(`Invalid format: ${format}`);
|
||||
}
|
||||
|
||||
await Promise.all(environments.map(async (env) => {
|
||||
const content = await stringifyEnvironment(env, { format });
|
||||
let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`);
|
||||
const filePath = path.join(envDirPath, sanitizedEnvFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
}));
|
||||
};
|
||||
// create folder and files based on collection
|
||||
await parseCollectionItems(coll.items, collectionPath);
|
||||
await parseEnvironments(coll.environments, collectionPath);
|
||||
|
||||
const getBrunoJsonConfig = (collection) => {
|
||||
let brunoConfig = collection.brunoConfig;
|
||||
const { size, filesCount } = await getCollectionStats(collectionPath);
|
||||
brunoConfig.size = size;
|
||||
brunoConfig.filesCount = filesCount;
|
||||
|
||||
if (!brunoConfig) {
|
||||
brunoConfig = {
|
||||
version: '1',
|
||||
name: collection.name,
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
}
|
||||
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
|
||||
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
|
||||
|
||||
return brunoConfig;
|
||||
};
|
||||
mainWindow.webContents.send('main:collection-import-ended', coll.uid);
|
||||
|
||||
await createDirectory(collectionPath);
|
||||
successfulImports.push({
|
||||
path: collectionPath,
|
||||
name: coll.name
|
||||
});
|
||||
// Increment completed imports
|
||||
completedImports++;
|
||||
} catch (error) {
|
||||
mainWindow.webContents.send('main:collection-import-failed', coll.uid, {
|
||||
message: `Error ${error.message}`
|
||||
});
|
||||
console.error(`Failed to import collection: ${coll.name}, Error: ${error.message}`);
|
||||
|
||||
const uid = generateUidBasedOnHash(collectionPath);
|
||||
let brunoConfig = getBrunoJsonConfig(collection);
|
||||
// Increment failed imports
|
||||
failedImports++;
|
||||
|
||||
if (format === 'yml') {
|
||||
brunoConfig.opencollection = '1.0.0';
|
||||
const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
|
||||
await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent);
|
||||
} else if (format === 'bru') {
|
||||
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
|
||||
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
|
||||
|
||||
const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
|
||||
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
|
||||
} else {
|
||||
throw new Error(`Invalid format: ${format}`);
|
||||
// Continue with next collection instead of breaking
|
||||
continue;
|
||||
}
|
||||
|
||||
const { size, filesCount } = await getCollectionStats(collectionPath);
|
||||
brunoConfig.size = size;
|
||||
brunoConfig.filesCount = filesCount;
|
||||
|
||||
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
|
||||
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
|
||||
|
||||
// create folder and files based on collection
|
||||
await parseCollectionItems(collection.items, collectionPath);
|
||||
await parseEnvironments(collection.environments, collectionPath);
|
||||
|
||||
return collectionPath;
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Send final status when all collections have been processed (either succeeded or failed)
|
||||
if ((completedImports + failedImports) === collections.length) {
|
||||
mainWindow.webContents.send('main:all-collections-import-ended', {
|
||||
message: `Import completed. ${completedImports} collections imported successfully, ${failedImports} failed.`,
|
||||
status: {
|
||||
total: collections.length,
|
||||
succeeded: completedImports,
|
||||
failed: failedImports
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: {
|
||||
count: completedImports,
|
||||
items: successfulImports
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:clone-folder', async (event, itemFolder, collectionPath, collectionPathname) => {
|
||||
@@ -2320,6 +2371,14 @@ const registerMainEventHandlers = (mainWindow, watcher) => {
|
||||
app.addRecentDocument(pathname);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:scan-for-bruno-files', (event, dir) => {
|
||||
try {
|
||||
return scanForBrunoFiles(dir);
|
||||
} catch (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// The app listen for this event and allows the user to save unsaved requests before closing the app
|
||||
ipcMain.on('main:start-quit-flow', () => {
|
||||
mainWindow.webContents.send('main:start-quit-flow');
|
||||
|
||||
22
packages/bruno-electron/src/ipc/git.js
Normal file
22
packages/bruno-electron/src/ipc/git.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { cloneGitRepository } = require('../utils/git');
|
||||
const { createDirectory, removeDirectory } = require('../utils/filesystem');
|
||||
|
||||
const registerGitIpc = (mainWindow) => {
|
||||
ipcMain.handle('renderer:clone-git-repository', async (event, { url, path, processUid }) => {
|
||||
let directoryCreated = false;
|
||||
try {
|
||||
await createDirectory(path);
|
||||
directoryCreated = true;
|
||||
await cloneGitRepository(mainWindow, { url, path, processUid });
|
||||
return 'Repository cloned successfully';
|
||||
} catch (error) {
|
||||
if (directoryCreated) {
|
||||
await removeDirectory(path);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = registerGitIpc;
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ipcMain, nativeTheme } = require('electron');
|
||||
const { getPreferences, savePreferences } = require('../store/preferences');
|
||||
const { getGitVersion } = require('../utils/git');
|
||||
const { globalEnvironmentsStore } = require('../store/global-environments');
|
||||
const { getCachedSystemProxy, refreshSystemProxy } = require('../store/system-proxy');
|
||||
|
||||
@@ -20,6 +21,9 @@ const registerPreferencesIpc = (mainWindow) => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const gitVersion = await getGitVersion();
|
||||
mainWindow.webContents.send('main:git-version', gitVersion);
|
||||
|
||||
ipcMain.emit('main:renderer-ready', mainWindow);
|
||||
});
|
||||
|
||||
|
||||
@@ -474,6 +474,33 @@ const isCollectionRootBruFile = (pathname, collectionPath) => {
|
||||
return dirname === collectionPath && basename === 'collection.bru';
|
||||
};
|
||||
|
||||
const scanForBrunoFiles = async (dir) => {
|
||||
const brunoFolders = [];
|
||||
|
||||
const scanDir = (currentDir) => {
|
||||
const files = fs.readdirSync(currentDir);
|
||||
|
||||
if (files && files.length) {
|
||||
files.forEach((file) => {
|
||||
const fullPath = path.join(currentDir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (['node_modules', '.git'].includes(file)) {
|
||||
return;
|
||||
}
|
||||
scanDir(fullPath);
|
||||
} else if (file === 'bruno.json') {
|
||||
brunoFolders.push(currentDir);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
scanDir(dir);
|
||||
return brunoFolders;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_GITIGNORE,
|
||||
isValidPathname,
|
||||
@@ -514,5 +541,6 @@ module.exports = {
|
||||
isValidDotEnvFilename,
|
||||
isBrunoConfigFile,
|
||||
isBruEnvironmentConfig,
|
||||
isCollectionRootBruFile
|
||||
isCollectionRootBruFile,
|
||||
scanForBrunoFiles
|
||||
};
|
||||
|
||||
1814
packages/bruno-electron/src/utils/git.js
Normal file
1814
packages/bruno-electron/src/utils/git.js
Normal file
File diff suppressed because it is too large
Load Diff
56
tests/import/bulk-import/001-multiple-files-upload.spec.ts
Normal file
56
tests/import/bulk-import/001-multiple-files-upload.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('Multiple Files Upload', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Multiple files can be uploaded together', async ({ page, createTmpDir }) => {
|
||||
const postmanFile = path.join(testDataDir, 'sample-postman.json');
|
||||
const insomniaFile = path.join(testDataDir, 'sample-insomnia.json');
|
||||
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
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"]', [postmanFile, insomniaFile]);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Bulk Import modal is now displayed
|
||||
const bulkImportModal = page.getByRole('dialog');
|
||||
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();
|
||||
|
||||
// Verify collection names are displayed
|
||||
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();
|
||||
await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible();
|
||||
|
||||
// Select a location and import
|
||||
await page.locator('#collection-location').fill(await createTmpDir('multiple-files-test'));
|
||||
await bulkImportModal.getByRole('button', { name: 'Import' }).click();
|
||||
|
||||
// Wait for import to complete (summary modal shows with "Close" button)
|
||||
await expect(bulkImportModal.getByRole('button', { name: 'Close' })).toBeVisible();
|
||||
|
||||
// Close the summary modal
|
||||
await bulkImportModal.getByRole('button', { name: 'Close' }).click();
|
||||
await bulkImportModal.waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify collections were imported successfully
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Sample Postman Collection')).toBeVisible();
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Sample Insomnia Collection')).toBeVisible();
|
||||
});
|
||||
});
|
||||
68
tests/import/bulk-import/002-all-collection-types.spec.ts
Normal file
68
tests/import/bulk-import/002-all-collection-types.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('All Collection Types Bulk Import', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('All 4 collection types appear in bulk import', async ({ page, createTmpDir }) => {
|
||||
const postmanFile = path.join(testDataDir, 'sample-postman.json');
|
||||
const insomniaFile = path.join(testDataDir, 'sample-insomnia.json');
|
||||
const brunoFile = path.join(testDataDir, 'sample-bruno.json');
|
||||
const openapiFile = path.join(testDataDir, 'sample-openapi.yaml');
|
||||
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
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"]', [postmanFile, insomniaFile, brunoFile, openapiFile]);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Bulk Import modal is displayed (no separate settings modal anymore)
|
||||
const bulkImportModal = page.getByRole('dialog');
|
||||
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();
|
||||
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();
|
||||
await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible();
|
||||
await expect(bulkImportModal.getByText('Sample Bruno Collection')).toBeVisible();
|
||||
await expect(bulkImportModal.getByText('Sample API')).toBeVisible();
|
||||
|
||||
// Verify that OpenAPI settings are visible (since one file is OpenAPI)
|
||||
await expect(bulkImportModal.getByText('Folder arrangement')).toBeVisible();
|
||||
await expect(bulkImportModal.getByTestId('grouping-dropdown')).toBeVisible();
|
||||
|
||||
// Optionally change grouping to path-based
|
||||
await bulkImportModal.getByTestId('grouping-dropdown').click();
|
||||
await bulkImportModal.getByTestId('grouping-option-path').click();
|
||||
|
||||
// Select a location and import
|
||||
await page.locator('#collection-location').fill(await createTmpDir('all-collection-types-test'));
|
||||
await bulkImportModal.getByRole('button', { name: 'Import' }).click();
|
||||
|
||||
// Wait for import to complete (summary modal shows with "Close" button)
|
||||
await expect(bulkImportModal.getByRole('button', { name: 'Close' })).toBeVisible();
|
||||
|
||||
// Close the summary modal
|
||||
await bulkImportModal.getByRole('button', { name: 'Close' }).click();
|
||||
await bulkImportModal.waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify all collections were imported successfully
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Sample Postman Collection')).toBeVisible();
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Sample Insomnia Collection')).toBeVisible();
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Sample Bruno Collection')).toBeVisible();
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Sample API')).toBeVisible();
|
||||
});
|
||||
});
|
||||
43
tests/import/test-data/sample-bruno.json
Normal file
43
tests/import/test-data/sample-bruno.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": "1",
|
||||
"uid": "bruno_test_collection_1",
|
||||
"name": "Sample Bruno Collection",
|
||||
"items": [
|
||||
{
|
||||
"uid": "bruno_test_request_1",
|
||||
"type": "http-request",
|
||||
"name": "Get Sample Data",
|
||||
"seq": 1,
|
||||
"request": {
|
||||
"url": "https://jsonplaceholder.typicode.com/todos/1",
|
||||
"method": "GET",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": {
|
||||
"mode": "none"
|
||||
},
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {},
|
||||
"vars": {},
|
||||
"assertions": [],
|
||||
"tests": "",
|
||||
"docs": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"environments": [],
|
||||
"activeEnvironmentUid": null,
|
||||
"root": {
|
||||
"request": {
|
||||
"headers": [],
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {},
|
||||
"vars": {},
|
||||
"tests": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
41
tests/import/test-data/sample-insomnia.json
Normal file
41
tests/import/test-data/sample-insomnia.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"_type": "export",
|
||||
"__export_format": 4,
|
||||
"__export_date": "2023-01-01T00:00:00.000Z",
|
||||
"__export_source": "insomnia.desktop.app:v2023.1.0",
|
||||
"resources": [
|
||||
{
|
||||
"_id": "req_123",
|
||||
"authentication": {},
|
||||
"body": {},
|
||||
"created": 1672531200000,
|
||||
"description": "",
|
||||
"headers": [],
|
||||
"isPrivate": false,
|
||||
"metaSortKey": -1672531200000,
|
||||
"method": "GET",
|
||||
"modified": 1672531200000,
|
||||
"name": "Get Posts",
|
||||
"parameters": [],
|
||||
"parentId": "wrk_456",
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"settingRebuildPath": true,
|
||||
"settingSendCookies": true,
|
||||
"settingStoreCookies": true,
|
||||
"url": "https://jsonplaceholder.typicode.com/posts",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "wrk_456",
|
||||
"created": 1672531200000,
|
||||
"description": "Sample Insomnia collection for testing",
|
||||
"modified": 1672531200000,
|
||||
"name": "Sample Insomnia Collection",
|
||||
"parentId": null,
|
||||
"scope": "collection",
|
||||
"_type": "workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
61
tests/import/test-data/sample-openapi.yaml
Normal file
61
tests/import/test-data/sample-openapi.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Sample API
|
||||
description: A simple API for testing OpenAPI imports
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://jsonplaceholder.typicode.com
|
||||
paths:
|
||||
/posts:
|
||||
get:
|
||||
summary: Get all posts
|
||||
description: Retrieve a list of all posts
|
||||
responses:
|
||||
'200':
|
||||
description: List of posts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
userId:
|
||||
type: integer
|
||||
post:
|
||||
summary: Create a new post
|
||||
description: Create a new post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
userId:
|
||||
type: integer
|
||||
responses:
|
||||
'201':
|
||||
description: Post created successfully
|
||||
/posts/{id}:
|
||||
get:
|
||||
summary: Get a specific post
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Post details
|
||||
44
tests/import/test-data/sample-postman.json
Normal file
44
tests/import/test-data/sample-postman.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Sample Postman Collection",
|
||||
"description": "A simple collection for testing imports",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Users",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "https://jsonplaceholder.typicode.com/users",
|
||||
"protocol": "https",
|
||||
"host": ["jsonplaceholder", "typicode", "com"],
|
||||
"path": ["users"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Create User",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "https://jsonplaceholder.typicode.com/users",
|
||||
"protocol": "https",
|
||||
"host": ["jsonplaceholder", "typicode", "com"],
|
||||
"path": ["users"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
tests/import/url-import/github-repository-import.spec.ts
Normal file
38
tests/import/url-import/github-repository-import.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('GitHub Repository URL Import', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('GitHub repository URL import', async ({ page }) => {
|
||||
const githubUrl = 'https://github.com/usebruno/github-rest-api-collection';
|
||||
|
||||
// Test GitHub repository import
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Select the GitHub tab
|
||||
await page.getByTestId('github-tab').click();
|
||||
|
||||
// Fill in the URL input
|
||||
await page.getByTestId('git-url-input').fill(githubUrl);
|
||||
await page.locator('#clone-git-button').click();
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Clone Git Repository modal is displayed
|
||||
const cloneModal = page.getByRole('dialog');
|
||||
await expect(cloneModal.locator('.bruno-modal-header-title')).toContainText('Clone Git Repository');
|
||||
|
||||
// Cleanup: close any open modals using Cancel button (avoids form validation)
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
});
|
||||
44
tests/import/url-import/insomnia-url-import.spec.ts
Normal file
44
tests/import/url-import/insomnia-url-import.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, openCollection } from '../../utils/page';
|
||||
|
||||
test.describe('Insomnia URL Import', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Insomnia URL import', async ({ page, createTmpDir }) => {
|
||||
const insomniaUrl = 'https://raw.githubusercontent.com/usebruno/bruno/refs/heads/main/tests/import/insomnia/fixtures/insomnia-v5.yaml';
|
||||
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByTestId('import-collection-modal');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByTestId('url-tab').click();
|
||||
await page.getByTestId('url-input').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('url-input').fill(insomniaUrl);
|
||||
await page.locator('#import-url-button').click();
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the collection location modal appears
|
||||
const locationModal = page.getByTestId('import-collection-location-modal');
|
||||
await expect(locationModal.getByText('Test API Collection v5')).toBeVisible();
|
||||
|
||||
// Select a location and import
|
||||
await page.locator('#collection-location').fill(await createTmpDir('test-api-collection-v5'));
|
||||
await locationModal.getByRole('button', { name: 'Import' }).click();
|
||||
|
||||
// Verify the collection was imported successfully and configure it
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Test API Collection v5')).toBeVisible();
|
||||
await openCollection(page, 'Test API Collection v5');
|
||||
|
||||
// Verify these folder names are present
|
||||
await expect(page.locator('.collection-item-name').getByText('API Tests')).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').getByText('Data Management')).toBeVisible();
|
||||
});
|
||||
});
|
||||
102
tests/import/url-import/openapi-url-import.spec.ts
Normal file
102
tests/import/url-import/openapi-url-import.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, openCollection } from '../../utils/page';
|
||||
|
||||
test.describe('OpenAPI URL Import', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Swagger/OpenAPI URL import', async ({ page, createTmpDir }) => {
|
||||
const openapiUrl = 'https://petstore.swagger.io/v2/swagger.json';
|
||||
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByTestId('import-collection-modal');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByTestId('url-tab').click();
|
||||
await page.getByTestId('url-input').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('url-input').fill(openapiUrl);
|
||||
await page.locator('#import-url-button').click();
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the collection location modal appears with OpenAPI settings
|
||||
const locationModal = page.getByTestId('import-collection-location-modal');
|
||||
await expect(locationModal.getByText('Swagger Petstore')).toBeVisible();
|
||||
|
||||
// Verify OpenAPI settings are available in the location modal
|
||||
await expect(locationModal.getByText('Folder arrangement')).toBeVisible();
|
||||
await expect(locationModal.getByTestId('grouping-dropdown')).toBeVisible();
|
||||
|
||||
// Select a location and import with default grouping (tags)
|
||||
await page.locator('#collection-location').fill(await createTmpDir('swagger-petstore'));
|
||||
await locationModal.getByRole('button', { name: 'Import' }).click();
|
||||
|
||||
// Verify the collection was imported successfully and configure it
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Swagger Petstore')).toBeVisible();
|
||||
await openCollection(page, 'Swagger Petstore');
|
||||
|
||||
// Verify these folder names are present (tag-based grouping)
|
||||
await expect(page.locator('.collection-item-name').getByText('pet')).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').getByText('store')).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').getByText('user')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Swagger/OpenAPI URL import with path-based grouping', async ({ page, createTmpDir }) => {
|
||||
const openapiUrl = 'https://petstore.swagger.io/v2/swagger.json';
|
||||
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByTestId('import-collection-modal');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByTestId('url-tab').click();
|
||||
await page.getByTestId('url-input').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('url-input').fill(openapiUrl);
|
||||
await page.locator('#import-url-button').click();
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the collection location modal appears with OpenAPI settings
|
||||
const locationModal = page.getByTestId('import-collection-location-modal');
|
||||
await expect(locationModal.getByText('Swagger Petstore')).toBeVisible();
|
||||
|
||||
// Verify OpenAPI settings are available in the location modal
|
||||
await expect(locationModal.getByText('Folder arrangement')).toBeVisible();
|
||||
|
||||
// Select path-based grouping from the dropdown
|
||||
await locationModal.getByTestId('grouping-dropdown').click();
|
||||
|
||||
// Wait for dropdown options to be visible and select path-based grouping
|
||||
await page.getByTestId('grouping-option-path').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('grouping-option-path').click();
|
||||
|
||||
// Select a location and import with path-based grouping
|
||||
await page.locator('#collection-location').fill(await createTmpDir('swagger-petstore-path'));
|
||||
await locationModal.getByRole('button', { name: 'Import' }).click();
|
||||
|
||||
// Verify the collection was imported successfully and configure it
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Swagger Petstore')).toBeVisible();
|
||||
await openCollection(page, 'Swagger Petstore');
|
||||
|
||||
// Verify that the collection has been imported with path-based grouping
|
||||
// Should have folders based on URL paths like 'pet', 'store', 'user'
|
||||
await expect(page.locator('.collection-item-name').getByText('pet')).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').getByText('store')).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').getByText('user')).toBeVisible();
|
||||
|
||||
// Expand the pet folder to check for nested path structure
|
||||
await page.locator('.collection-item-name').getByText('pet').click();
|
||||
|
||||
// Verify that the pet folder contains path-based subfolders like '{petId}'
|
||||
await expect(page.locator('.collection-item-name').getByText('{petId}')).toBeVisible();
|
||||
});
|
||||
});
|
||||
44
tests/import/url-import/postman-url-import.spec.ts
Normal file
44
tests/import/url-import/postman-url-import.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, openCollection } from '../../utils/page';
|
||||
|
||||
test.describe('Postman URL Import', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Postman URL import', async ({ page, createTmpDir }) => {
|
||||
const postmanUrl = 'https://raw.githubusercontent.com/usebruno/bruno/refs/heads/main/tests/import/postman/fixtures/postman-v21.json';
|
||||
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByTestId('import-collection-modal');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByTestId('url-tab').click();
|
||||
await page.getByTestId('url-input').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('url-input').fill(postmanUrl);
|
||||
await page.locator('#import-url-button').click();
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the collection location modal appears
|
||||
const locationModal = page.getByTestId('import-collection-location-modal');
|
||||
await expect(locationModal.getByText('Postman v2.1 Collection')).toBeVisible();
|
||||
|
||||
// Select a location and import
|
||||
await page.locator('#collection-location').fill(await createTmpDir('postman-v21-collection'));
|
||||
await locationModal.getByRole('button', { name: 'Import' }).click();
|
||||
|
||||
// Verify the collection was imported successfully and configure it
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Postman v2.1 Collection')).toBeVisible();
|
||||
await openCollection(page, 'Postman v2.1 Collection');
|
||||
|
||||
// Verify these folder names are present
|
||||
await expect(page.locator('.collection-item-name').getByText('Get Users')).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').getByText('Create User')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user