feat: new import modal (#5050)

This commit is contained in:
Pooja
2025-08-26 18:32:02 +05:30
committed by GitHub
parent f5b4dbd1a1
commit 5f938d77b4
6 changed files with 258 additions and 223 deletions

View File

@@ -30,8 +30,9 @@ export const CollectionItemDragPreview = () => {
clientOffset: monitor.getClientOffset(),
}));
if (!isDragging) return null;
if (!item.type) return null;
const { x, y } = clientOffset || {};
const shouldShowFolderIcon = !item.type || item.type === 'folder';
const shouldShowFolderIcon = item.type === 'folder';
return (
<StyledWrapper>
<div style={getItemStyles({ x, y })} className='p-2'>

View File

@@ -1,126 +1,204 @@
import React, { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
import importBrunoCollection from 'utils/importers/bruno-collection';
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import React, { useState, useEffect, useRef } from 'react';
import { IconLoader2, IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import fileDialog from 'file-dialog';
import jsyaml from 'js-yaml';
import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
const convertFileToObject = async (file) => {
const text = await file.text();
try {
if (file.type === 'application/json' || file.name.endsWith('.json')) {
return JSON.parse(text);
}
const parsed = jsyaml.load(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error();
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
}
};
const FullscreenLoader = ({ isLoading }) => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
);
};
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false)
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef(null);
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Import collection failed'))
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
let collection;
if (isPostmanCollection(data)) {
collection = await postmanToBruno(data);
}
else if (isInsomniaCollection(data)) {
collection = convertInsomniaToBruno(data);
}
else if (isOpenApiSpec(data)) {
collection = convertOpenapiToBruno(data);
}
else {
collection = await processBrunoCollection(data);
}
handleSubmit({ collection });
} catch (err) {
toastError(err, 'Import collection failed');
} finally {
setIsLoading(false);
}
};
const handleImportPostmanCollection = () => {
fileDialog({ accept: 'application/json' })
.then((...args) => {
setIsLoading(true);
return readFile(...args);
})
.then((collection) => postmanToBruno(collection))
.then((collection) => handleSubmit({ collection }))
.catch((err) => toastError(err, 'Postman Import collection failed'))
.finally(() => setIsLoading(false));
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await processFile(e.dataTransfer.files[0]);
}
};
const handleBrowseFiles = () => {
fileInputRef.current.click();
};
const handleFileInputChange = async (e) => {
if (e.target.files && e.target.files[0]) {
await processFile(e.target.files[0]);
}
};
if (isLoading) {
return <FullscreenLoader isLoading={isLoading} />;
}
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
};
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'application/json',
'application/yaml',
'application/x-yaml'
]
const handleImportOpenapiCollection = () => {
importOpenapiCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
};
const CollectionButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-1 text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const FullscreenLoader = () => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
// Cycle through loading messages for better UX
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
);
};
return (
<>
{isLoading && <FullscreenLoader />}
{!isLoading && (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileInputChange}
accept={acceptedFileTypes.join(',')}
/>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
Drop file to import or{' '}
<button
className="text-blue-500 underline cursor-pointer"
onClick={handleBrowseFiles}
>
choose a file
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Supports Bruno, Postman, Insomnia, and OpenAPI v3 formats
</p>
</div>
</div>
</Modal>
)}
</>
</div>
</div>
</Modal>
);
};

View File

@@ -1,43 +1,16 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { validateSchema, transformItemsInCollection, updateUidsInCollection, hydrateSeqInCollection } from './common';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const parseJsonCollection = (str) => {
return new Promise((resolve, reject) => {
try {
let parsed = JSON.parse(str);
return resolve(parsed);
} catch (err) {
console.log(err);
reject(new BrunoError('Unable to parse the collection json file'));
}
});
export const processBrunoCollection = async (jsonData) => {
try {
let collection = hydrateSeqInCollection(jsonData);
collection = updateUidsInCollection(collection);
collection = transformItemsInCollection(collection);
await validateSchema(collection);
return collection;
} catch (err) {
console.error('Error processing Bruno collection:', err);
throw new BrunoError('Import collection failed');
}
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parseJsonCollection)
.then(hydrateSeqInCollection)
.then(updateUidsInCollection)
.then(transformItemsInCollection)
.then(validateSchema)
.then((collection) => resolve({ collection }))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
});
});
};
export default importCollection;

View File

@@ -1,43 +1,26 @@
import jsyaml from 'js-yaml';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { insomniaToBruno } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
try {
// try to load JSON
const parsedData = JSON.parse(e.target.result);
resolve(parsedData);
} catch (jsonError) {
// not a valid JSOn, try yaml
try {
const parsedData = jsyaml.load(e.target.result, { schema: jsyaml.CORE_SCHEMA });
resolve(parsedData);
} catch (yamlError) {
console.error('Error parsing the file :', jsonError, yamlError);
reject(new BrunoError('Import collection failed'));
}
}
};
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
export const convertInsomniaToBruno = (data) => {
try {
return insomniaToBruno(data);
} catch (err) {
console.error('Error converting Insomnia to Bruno:', err);
throw new BrunoError('Import collection failed: ' + err.message);
}
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
.then((collection) => insomniaToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);
reject(new BrunoError('Import collection failed: ' + err.message));
});
});
};
export const isInsomniaCollection = (data) => {
// Check for Insomnia v5 collection format collection array must be present
if (typeof data.type === 'string' && data.type.startsWith('collection.insomnia.rest/5')) {
return Array.isArray(data.collection);
}
export default importCollection;
// Check for Insomnia v4 export format must have __export_format and resources array
if (data._type === 'export') {
return Array.isArray(data.resources) && typeof data.__export_format === 'number';
}
return false;
};

View File

@@ -1,43 +1,27 @@
import jsyaml from 'js-yaml';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { openApiToBruno } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
try {
// try to load JSON
const parsedData = JSON.parse(e.target.result);
resolve(parsedData);
} catch (jsonError) {
// not a valid JSOn, try yaml
try {
const parsedData = jsyaml.load(e.target.result);
resolve(parsedData);
} catch (yamlError) {
console.error('Error parsing the file :', jsonError, yamlError);
reject(new BrunoError('Import collection failed'));
}
}
};
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
export const convertOpenapiToBruno = (data) => {
try {
return openApiToBruno(data);
} catch (err) {
console.error('Error converting OpenAPI to Bruno:', err);
throw new BrunoError('Import collection failed: ' + err.message);
}
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
.then((collection) => openApiToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);
reject(new BrunoError('Import collection failed: ' + err.message));
});
});
};
export const isOpenApiSpec = (data) => {
if (typeof data.info !== 'object' || data.info === null) {
return false;
}
export default importCollection;
if (typeof data.openapi === 'string' && data.openapi.trim().length) {
return true;
}
if (typeof data.swagger === 'string' && data.swagger.trim().length) {
return true;
}
return false;
};

View File

@@ -22,4 +22,20 @@ const postmanToBruno = (collection) => {
});
};
export { postmanToBruno, readFile };
const isPostmanCollection = (data) => {
const info = data.info;
if (!info || typeof info !== 'object') {
return false;
}
const schema = info.schema;
// Accept schemas hosted at schema.getpostman.com or schema.postman.com
const schemaRegex = /^https:\/\/schema\.(?:getpostman|postman)\.com\//;
if (typeof schema === 'string' && schemaRegex.test(schema)) {
return true;
}
return false;
};
export { postmanToBruno, readFile, isPostmanCollection };