feat: folder sequencing (#4595)

Co-authored-by: Pooja Belaramani <pooja@usebruno.com>
Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: lohit <lohit@usebruno.com>
This commit is contained in:
lohit 2025-05-05 16:52:00 +05:30 committed by GitHub
parent 526fcabffe
commit 38c307d6f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1280 additions and 612 deletions

62
package-lock.json generated
View File

@ -54,6 +54,7 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
@ -1474,6 +1475,7 @@
"version": "7.26.0", "version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
"integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
@ -1504,6 +1506,7 @@
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -1521,6 +1524,7 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
@ -1800,6 +1804,7 @@
"version": "7.26.0", "version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
"integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.25.9", "@babel/template": "^7.25.9",
@ -7784,6 +7789,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
@ -7806,6 +7812,7 @@
"version": "12.2.3", "version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/linkify-it": "*", "@types/linkify-it": "*",
@ -7816,6 +7823,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
@ -11060,6 +11068,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": { "node_modules/cookie": {
@ -12670,6 +12679,7 @@
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -13637,6 +13647,7 @@
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -24196,7 +24207,7 @@
"version": "4.9.5", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -26303,9 +26314,7 @@
"@rollup/plugin-typescript": "^12.1.2", "@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"cheerio": "^1.0.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"playwright": "^1.52.0",
"rollup": "3.29.5", "rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0", "rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-peer-deps-external": "^2.2.4",
@ -26815,21 +26824,6 @@
} }
} }
}, },
"packages/bruno-common/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"packages/bruno-common/node_modules/ms": { "packages/bruno-common/node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -26837,38 +26831,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"packages/bruno-common/node_modules/playwright": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"packages/bruno-common/node_modules/playwright-core": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/bruno-common/node_modules/typescript": { "packages/bruno-common/node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",

View File

@ -72,7 +72,7 @@ const Info = ({ collection }) => {
</div> </div>
</div> </div>
</div> </div>
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />} {showShareCollectionModal && <ShareCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={handleToggleShowShareCollectionModal(false)} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -28,7 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
tab = folderLevelSettingsSelectedTab[folder?.uid]; tab = folderLevelSettingsSelectedTab[folder?.uid];
} }
const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root; const folderRoot = folder?.root;
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req; const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
const hasTests = folderRoot?.request?.tests; const hasTests = folderRoot?.request?.tests;

View File

@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div> </div>
</div> </div>
{generateCodeItemModalOpen && ( {generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} /> <GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)} )}
</StyledWrapper> </StyledWrapper>
); );

View File

@ -261,13 +261,14 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
return ( return (
<Fragment> <Fragment>
{showAddNewRequestModal && ( {showAddNewRequestModal && (
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} /> <NewRequest collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowAddNewRequestModal(false)} />
)} )}
{showCloneRequestModal && ( {showCloneRequestModal && (
<CloneCollectionItem <CloneCollectionItem
item={currentTabItem} item={currentTabItem}
collection={collection} collectionUid={collection.uid}
collectionPathname={collection.pathname}
onClose={() => setShowCloneRequestModal(false)} onClose={() => setShowCloneRequestModal(false)}
/> />
)} )}

View File

@ -79,7 +79,7 @@ const RequestTabs = () => {
return ( return (
<StyledWrapper className={getRootClassname()}> <StyledWrapper className={getRootClassname()}>
{newRequestModalOpen && ( {newRequestModalOpen && (
<NewRequest collection={activeCollection} onClose={() => setNewRequestModalOpen(false)} /> <NewRequest collectionUid={activeCollection?.uid} collectionPathname={activeCollection?.pathname} onClose={() => setNewRequestModalOpen(false)} />
)} )}
{collectionRequestTabs && collectionRequestTabs.length ? ( {collectionRequestTabs && collectionRequestTabs.length ? (
<> <>

View File

@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection'; import exportPostmanCollection from 'utils/exporters/postman-collection';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index'; import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid } from 'utils/collections/index';
const ShareCollection = ({ onClose, collection }) => { const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const handleExportBrunoCollection = () => { const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection); const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));

View File

@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
@ -11,11 +11,13 @@ import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay'; import PathDisplay from 'components/PathDisplay';
import { useState } from 'react'; import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from "@tabler/icons"; import { IconArrowBackUp, IconEdit } from "@tabler/icons";
import { findCollectionByUid } from 'utils/collections/index';
const CloneCollection = ({ onClose, collection }) => { const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef(); const inputRef = useRef();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false); const [isEditing, toggleEditing] = useState(false);
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const { name } = collection; const { name } = collection;
const formik = useFormik({ const formik = useFormik({
@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
values.collectionName, values.collectionName,
values.collectionFolderName, values.collectionFolderName,
values.collectionLocation, values.collectionLocation,
collection.pathname collection?.pathname
) )
) )
.then(() => { .then(() => {

View File

@ -15,7 +15,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown'; import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const CloneCollectionItem = ({ collection, item, onClose }) => { const CloneCollectionItem = ({ collectionUid, collectionPathname, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isFolder = isItemAFolder(item); const isFolder = isItemAFolder(item);
const inputRef = useRef(); const inputRef = useRef();
@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value)) .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}), }),
onSubmit: (values) => { onSubmit: (values) => {
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid)) dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
.then(() => { .then(() => {
toast.success('Request cloned!'); toast.success('Request cloned!');
onClose(); onClose();
@ -172,8 +172,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
) : ( ) : (
<div className='relative flex flex-row gap-1 items-center justify-between'> <div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay <PathDisplay
collection={collection} dirName={path.relative(collectionPathname, path.dirname(item?.pathname))}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename} baseName={formik.values.filename}
/> />
</div> </div>

View File

@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections'; import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const DeleteCollectionItem = ({ onClose, item, collection }) => { const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isFolder = isItemAFolder(item); const isFolder = isItemAFolder(item);
const onConfirm = () => { const onConfirm = () => {
dispatch(deleteItem(item.uid, collection.uid)).then(() => { dispatch(deleteItem(item.uid, collectionUid)).then(() => {
if (isFolder) { if (isFolder) {
// close all tabs that belong to the folder // close all tabs that belong to the folder

View File

@ -10,9 +10,11 @@ import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index'; import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const GenerateCodeItem = ({ collection, item, onClose }) => { const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages(); const languages = getLanguages();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });

View File

@ -16,7 +16,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown'; import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const RenameCollectionItem = ({ collection, item, onClose }) => { const RenameCollectionItem = ({ collectionUid, collectionPathname, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isFolder = isItemAFolder(item); const isFolder = isItemAFolder(item);
const inputRef = useRef(); const inputRef = useRef();
@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
return; return;
} }
if (!isFolder && item.draft) { if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true)); await dispatch(saveRequest(item.uid, collectionUid, true));
} }
const { name: newName, filename: newFilename } = values; const { name: newName, filename: newFilename } = values;
try { try {
let renameConfig = { let renameConfig = {
itemUid: item.uid, itemUid: item.uid,
collectionUid: collection.uid, collectionUid,
}; };
renameConfig['newName'] = newName; renameConfig['newName'] = newName;
if (itemFilename !== newFilename) { if (itemFilename !== newFilename) {
@ -191,8 +191,7 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
) : ( ) : (
<div className='relative flex flex-row gap-1 items-center justify-between'> <div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay <PathDisplay
collection={collection} dirName={path.relative(collectionPathname, path.dirname(item?.pathname))}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename} baseName={formik.values.filename}
/> />
</div> </div>

View File

@ -2,16 +2,18 @@ import React from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs'; import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions'; import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections'; import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections'; import { areItemsLoading } from 'utils/collections';
const RunCollectionItem = ({ collection, item, onClose }) => { const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const onSubmit = (recursive) => { const onSubmit = (recursive) => {
dispatch( dispatch(
addTab({ addTab({
@ -34,8 +36,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
const recursiveRunLength = getRequestsCount(flattenedItems); const recursiveRunLength = getRequestsCount(flattenedItems);
const isFolderLoading = areItemsLoading(item); const isFolderLoading = areItemsLoading(item);
console.log(item);
console.log(isFolderLoading);
return ( return (
<StyledWrapper> <StyledWrapper>

View File

@ -22,6 +22,65 @@ const Wrapper = styled.div`
height: 1.875rem; height: 1.875rem;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
position: relative;
/* Common styles for drop indicators */
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.dragAndDrop.border};
opacity: 0;
pointer-events: none;
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
/* Drop target styles */
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
&::before,
&::after {
opacity: 0;
}
}
&.drop-target-above {
&::before {
opacity: 1;
height: 2px;
}
}
&.drop-target-below {
&::after {
opacity: 1;
height: 2px;
}
}
/* Inside drop target style */
&.drop-target {
&::before {
top: 0;
bottom: 0;
height: 100%;
opacity: 1;
background: ${(props) => props.theme.dragAndDrop.hoverBg};
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
// border-radius: 4px;
}
}
.rotate-90 { .rotate-90 {
transform: rotateZ(90deg); transform: rotateZ(90deg);
@ -45,6 +104,20 @@ const Wrapper = styled.div`
} }
} }
&.item-target {
background: #ccc3;
}
&.item-seperator {
.seperator {
bottom: 0px;
position: absolute;
height: 3px;
width: 100%;
background: #ccc3;
}
}
&.item-focused-in-tab { &.item-focused-in-tab {
background: ${(props) => props.theme.sidebar.collection.item.bg}; background: ${(props) => props.theme.sidebar.collection.item.bg};

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react'; import React, { useState, useRef, forwardRef } from 'react';
import range from 'lodash/range'; import range from 'lodash/range';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import classnames from 'classnames'; import classnames from 'classnames';
@ -6,7 +6,7 @@ import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons'; import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import { handleCollectionItemDrop, moveItem, sendRequest, showInFolder, updateItemsSequences } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections'; import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown'; import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest'; import NewRequest from 'components/Sidebar/NewRequest';
@ -26,13 +26,21 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index'; import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon'; import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs'; import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
const CollectionItem = ({ item, collection, searchText }) => { const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs); const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
const isSidebarDragging = useSelector((state) => state.app.isDragging); const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch(); const dispatch = useDispatch();
const collectionItemRef = useRef(null);
// We use a single ref for drag and drop.
const ref = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false); const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false); const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@ -44,9 +52,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length; const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed; const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const isFolder = isItemAFolder(item);
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [{ isDragging }, drag] = useDrag({ const [{ isDragging }, drag] = useDrag({
type: `collection-item-${collection.uid}`, type: `collection-item-${collectionUid}`,
item: item, item: item,
collect: (monitor) => ({ collect: (monitor) => ({
isDragging: monitor.isDragging() isDragging: monitor.isDragging()
@ -56,21 +67,51 @@ const CollectionItem = ({ item, collection, searchText }) => {
} }
}); });
const [{ isOver }, drop] = useDrop({ const determineDropType = (monitor) => {
accept: `collection-item-${collection.uid}`, const hoverBoundingRect = ref.current?.getBoundingClientRect();
drop: (draggedItem) => { const clientOffset = monitor.getClientOffset();
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid)); if (!hoverBoundingRect || !clientOffset) return null;
const clientY = clientOffset.y - hoverBoundingRect.top;
const folderUpperThreshold = hoverBoundingRect.height * 0.35;
const fileUpperThreshold = hoverBoundingRect.height * 0.5;
if (isItemAFolder(item)) {
return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
} else {
return clientY < fileUpperThreshold ? 'adjacent' : null;
}
};
const [{ isOver, canDrop }, drop] = useDrop({
accept: `collection-item-${collectionUid}`,
hover: (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
setDropType(dropType);
}, },
canDrop: (draggedItem) => { drop: async (draggedItem, monitor) => {
return draggedItem.uid !== item.uid; const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
if (!dropType) return;
await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
setDropType(null);
}, },
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
collect: (monitor) => ({ collect: (monitor) => ({
isOver: monitor.isOver(), isOver: monitor.isOver()
}), }),
}); });
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef(); const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => { const MenuIcon = forwardRef((props, ref) => {
return ( return (
@ -84,13 +125,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
'rotate-90': !itemIsCollapsed 'rotate-90': !itemIsCollapsed
}); });
const itemRowClassName = classnames('flex collection-item-name items-center', { const itemRowClassName = classnames('flex collection-item-name relative items-center', {
'item-focused-in-tab': item.uid == activeTabUid, 'item-focused-in-tab': isTabForItemActive,
'item-hovered': isOver 'item-hovered': isOver && canDrop,
'drop-target': isOver && dropType === 'inside',
'drop-target-above': isOver && dropType === 'adjacent'
}); });
const handleRun = async () => { const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) => dispatch(sendRequest(item, collectionUid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, { toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000 duration: 5000
}) })
@ -101,12 +144,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
if (event && event.detail != 1) return; if (event && event.detail != 1) return;
//scroll to the active tab //scroll to the active tab
setTimeout(scrollToTheActiveTab, 50); setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item); const isRequest = isItemARequest(item);
if (isRequest) { if (isRequest) {
dispatch(hideHomePage()); dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) { if (isTabForItemPresent) {
dispatch( dispatch(
focusTab({ focusTab({
uid: item.uid uid: item.uid
@ -114,11 +155,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
); );
return; return;
} }
dispatch( dispatch(
addTab({ addTab({
uid: item.uid, uid: item.uid,
collectionUid: collection.uid, collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab(item), requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request', type: 'request',
}) })
@ -127,14 +167,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch( dispatch(
addTab({ addTab({
uid: item.uid, uid: item.uid,
collectionUid: collection.uid, collectionUid: collectionUid,
type: 'folder-settings', type: 'folder-settings',
}) })
); );
dispatch( dispatch(
collectionFolderClicked({ collectionFolderClicked({
itemUid: item.uid, itemUid: item.uid,
collectionUid: collection.uid collectionUid: collectionUid
}) })
); );
} }
@ -146,10 +186,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch( dispatch(
collectionFolderClicked({ collectionFolderClicked({
itemUid: item.uid, itemUid: item.uid,
collectionUid: collection.uid collectionUid: collectionUid
}) })
); );
} };
const handleRightClick = (event) => { const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current; const _menuDropdown = dropdownTippyRef.current;
@ -164,7 +204,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
let indents = range(item.depth); let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', { const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging 'is-sidebar-dragging': isSidebarDragging
@ -183,49 +222,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
} }
const handleDoubleClick = (event) => { const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid })) dispatch(makeTabPermanent({ uid: item.uid }));
}; };
// we need to sort request items by seq property // Sort items by their "seq" property.
const sortRequestItems = (items = []) => { const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq); return items.sort((a, b) => a.seq - b.seq);
}; };
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
focusTab({
uid: item.uid
})
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
})
);
return;
}
};
const handleShowInFolder = () => { const handleShowInFolder = () => {
dispatch(showInFolder(item.pathname)).catch((error) => { dispatch(showInFolder(item.pathname)).catch((error) => {
console.error('Error opening the folder', error); console.error('Error opening the folder', error);
@ -233,62 +237,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
}); });
}; };
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i))); const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i))); const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (
(item?.request?.url !== '') ||
(item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(focusTab({ uid: item.uid }));
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid,
type: 'folder-settings'
})
);
}
};
return ( return (
<StyledWrapper className={className}> <StyledWrapper className={className}>
{renameItemModalOpen && ( {renameItemModalOpen && (
<RenameCollectionItem item={item} collection={collection} onClose={() => setRenameItemModalOpen(false)} /> <RenameCollectionItem item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setRenameItemModalOpen(false)} />
)} )}
{cloneItemModalOpen && ( {cloneItemModalOpen && (
<CloneCollectionItem item={item} collection={collection} onClose={() => setCloneItemModalOpen(false)} /> <CloneCollectionItem item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setCloneItemModalOpen(false)} />
)} )}
{deleteItemModalOpen && ( {deleteItemModalOpen && (
<DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} /> <DeleteCollectionItem item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setDeleteItemModalOpen(false)} />
)} )}
{newRequestModalOpen && ( {newRequestModalOpen && (
<NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} /> <NewRequest item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setNewRequestModalOpen(false)} />
)} )}
{newFolderModalOpen && ( {newFolderModalOpen && (
<NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} /> <NewFolder item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setNewFolderModalOpen(false)} />
)} )}
{runCollectionModalOpen && ( {runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} /> <RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)} )}
{generateCodeItemModalOpen && ( {generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} /> <GenerateCodeItem collectionUid={collectionUid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)} )}
{itemInfoModalOpen && ( {itemInfoModalOpen && (
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} /> <CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
)} )}
<div className={itemRowClassName} ref={collectionItemRef}> <div
className={itemRowClassName}
ref={(node) => {
ref.current = node;
drag(drop(node));
}}
>
<div className="flex items-center h-full w-full"> <div className="flex items-center h-full w-full">
{indents && indents.length {indents && indents.length
? indents.map((i) => { ? indents.map((i) => (
return ( <div
<div onClick={handleClick}
onClick={handleClick} onContextMenu={handleRightClick}
onContextMenu={handleRightClick} onDoubleClick={handleDoubleClick}
onDoubleClick={handleDoubleClick} className="indent-block"
className="indent-block" key={i}
key={i} style={{ width: 16, minWidth: 16, height: '100%' }}
style={{ >
width: 16, &nbsp;{/* Indent */}
minWidth: 16, </div>
height: '100%' ))
}}
>
&nbsp;{/* Indent */}
</div>
);
})
: null} : null}
<div <div
className="flex flex-grow items-center h-full overflow-hidden" className="flex flex-grow items-center h-full overflow-hidden"
style={{ style={{ paddingLeft: 8 }}
paddingLeft: 8
}}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleRightClick} onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
@ -304,10 +335,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
/> />
) : null} ) : null}
</div> </div>
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
<div
className="ml-1 flex w-full h-full items-center overflow-hidden"
>
<CollectionItemIcon item={item} /> <CollectionItemIcon item={item} />
<span className="item-name" title={item.name}> <span className="item-name" title={item.name}>
{item.name} {item.name}
@ -429,17 +457,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div> </div>
</div> </div>
</div> </div>
{!itemIsCollapsed ? ( {!itemIsCollapsed ? (
<div> <div>
{folderItems && folderItems.length {folderItems && folderItems.length
? folderItems.map((i) => { ? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />; return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
}) })
: null} : null}
{requestItems && requestItems.length {requestItems && requestItems.length
? requestItems.map((i) => { ? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />; return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
}) })
: null} : null}
</div> </div>
@ -448,4 +475,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
); );
}; };
export default CollectionItem; export default React.memo(CollectionItem);

View File

@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons'; import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions'; import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RemoveCollection = ({ onClose, collection }) => { const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const onConfirm = () => { const onConfirm = () => {
dispatch(removeCollection(collection.uid)) dispatch(removeCollection(collection.uid))

View File

@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions'; import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RenameCollection = ({ collection, onClose }) => { const RenameCollection = ({ collectionUid, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const inputRef = useRef(); const inputRef = useRef();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {

View File

@ -62,6 +62,36 @@ const Wrapper = styled.div`
color: white; color: white;
} }
} }
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-above {
border: none;
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-top: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-below {
border: none;
border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-bottom: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
}
.collection-name.drop-target {
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-radius: 4px;
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
margin: -2px;
transition: ${(props) => props.theme.dragAndDrop.transition};
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
} }
#sidebar-collection-name { #sidebar-collection-name {

View File

@ -6,7 +6,7 @@ import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons'; import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown'; import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections'; import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions'; import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest'; import NewRequest from 'components/Sidebar/NewRequest';
@ -33,7 +33,7 @@ const Collection = ({ collection, searchText }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isLoading = areItemsLoading(collection); const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null); const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef(); const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => { const MenuIcon = forwardRef((props, ref) => {
@ -144,7 +144,7 @@ const Collection = ({ collection, searchText }) => {
drop: (draggedItem, monitor) => { drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType(); const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) { if (isCollectionItem(itemType)) {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid)) dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
} else { } else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection})); dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
} }
@ -170,33 +170,28 @@ const Collection = ({ collection, searchText }) => {
}); });
// we need to sort request items by seq property // we need to sort request items by seq property
const sortRequestItems = (items = []) => { const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq); return items.sort((a, b) => a.seq - b.seq);
}; };
// we need to sort folder items by name alphabetically const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
const sortFolderItems = (items = []) => { const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
return ( return (
<StyledWrapper className="flex flex-col"> <StyledWrapper className="flex flex-col">
{showNewRequestModal && <NewRequest collection={collection} onClose={() => setShowNewRequestModal(false)} />} {showNewRequestModal && <NewRequest collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />} {showNewFolderModal && <NewFolder collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowNewFolderModal(false)} />}
{showRenameCollectionModal && ( {showRenameCollectionModal && (
<RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} /> <RenameCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowRenameCollectionModal(false)} />
)} )}
{showRemoveCollectionModal && ( {showRemoveCollectionModal && (
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} /> <RemoveCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowRemoveCollectionModal(false)} />
)} )}
{showShareCollectionModal && ( {showShareCollectionModal && (
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} /> <ShareCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowShareCollectionModal(false)} />
)} )}
{showCloneCollectionModalOpen && ( {showCloneCollectionModalOpen && (
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} /> <CloneCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowCloneCollectionModalOpen(false)} />
)} )}
<div className={collectionRowClassName} <div className={collectionRowClassName}
ref={collectionRef} ref={collectionRef}
@ -300,16 +295,12 @@ const Collection = ({ collection, searchText }) => {
<div> <div>
{!collectionIsCollapsed ? ( {!collectionIsCollapsed ? (
<div> <div>
{folderItems && folderItems.length {folderItems?.map?.((i) => {
? folderItems.map((i) => { return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />; })}
}) {requestItems?.map?.((i) => {
: null} return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
{requestItems && requestItems.length })}
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown";
import { IconCaretDown } from "@tabler/icons"; import { IconCaretDown } from "@tabler/icons";
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const NewFolder = ({ collection, item, onClose }) => { const NewFolder = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const inputRef = useRef(); const inputRef = useRef();
const [isEditing, toggleEditing] = useState(false); const [isEditing, toggleEditing] = useState(false);
@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => {
}) })
}), }),
onSubmit: (values) => { onSubmit: (values) => {
dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null)) dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
.then(() => { .then(() => {
toast.success('New folder created!'); toast.success('New folder created!');
onClose(); onClose();

View File

@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
import path from 'utils/common/path'; import path from 'utils/common/path';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections'; import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions'; import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs'; import { addTab } from 'providers/ReduxStore/slices/tabs';
@ -20,9 +20,11 @@ import Portal from 'components/Portal';
import Help from 'components/Help'; import Help from 'components/Help';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => { const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const inputRef = useRef(); const inputRef = useRef();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { const {
brunoConfig: { presets: collectionPresets = {} } brunoConfig: { presets: collectionPresets = {} }
} = collection; } = collection;
@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType, requestType: values.requestType,
requestUrl: values.requestUrl, requestUrl: values.requestUrl,
requestMethod: values.requestMethod, requestMethod: values.requestMethod,
collectionUid: collection.uid collectionUid: collectionUid
}) })
) )
.then(() => { .then(() => {
dispatch( dispatch(
addTab({ addTab({
uid: uid, uid: uid,
collectionUid: collection.uid, collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType }) requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
}) })
); );
@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: curlRequestTypeDetected, requestType: curlRequestTypeDetected,
requestUrl: request.url, requestUrl: request.url,
requestMethod: request.method, requestMethod: request.method,
collectionUid: collection.uid, collectionUid: collectionUid,
itemUid: item ? item.uid : null, itemUid: item ? item.uid : null,
headers: request.headers, headers: request.headers,
body: request.body, body: request.body,
@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType, requestType: values.requestType,
requestUrl: values.requestUrl, requestUrl: values.requestUrl,
requestMethod: values.requestMethod, requestMethod: values.requestMethod,
collectionUid: collection.uid, collectionUid: collectionUid,
itemUid: item ? item.uid : null itemUid: item ? item.uid : null
}) })
) )
@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
) : ( ) : (
<div className='relative flex flex-row gap-1 items-center justify-between'> <div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay <PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''} baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
/> />
</div> </div>

View File

@ -13,12 +13,9 @@ import {
findEnvironmentInCollection, findEnvironmentInCollection,
findItemInCollection, findItemInCollection,
findParentItemInCollection, findParentItemInCollection,
getItemsToResequence,
isItemAFolder, isItemAFolder,
refreshUidsInItem, refreshUidsInItem,
isItemARequest, isItemARequest,
moveCollectionItem,
moveCollectionItemToRootOfCollection,
transformRequestToSaveToFilesystem transformRequestToSaveToFilesystem
} from 'utils/collections'; } from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common'; import { uuid, waitForNextTick } from 'utils/common';
@ -47,8 +44,7 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform'; import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { getGlobalEnvironmentVariables } from 'utils/collections/index'; import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex'; import { sanitizeName } from 'utils/common/regex';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index'; import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
@ -358,6 +354,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => { export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!collection) { if (!collection) {
@ -372,10 +370,27 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
if (!folderWithSameNameExists) { if (!folderWithSameNameExists) {
const fullName = path.join(collection.pathname, directoryName); const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer ipcRenderer
.invoke('renderer:new-folder', fullName, folderName) .invoke('renderer:new-folder', fullName)
.then(() => resolve()) .then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error)); .catch((error) => reject(error));
} else { } else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed')); return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@ -392,8 +407,26 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer ipcRenderer
.invoke('renderer:new-folder', fullName, folderName) .invoke('renderer:new-folder', fullName)
.then(() => resolve()) .then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error)); .catch((error) => reject(error));
} else { } else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed')); return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@ -495,7 +528,8 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
set(item, 'name', newName); set(item, 'name', newName);
set(item, 'filename', newFilename); set(item, 'filename', newFilename);
set(item, 'root.meta.name', newName); set(item, 'root.meta.name', newName);
set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
const collectionPath = path.join(parentFolder.pathname, newFilename); const collectionPath = path.join(parentFolder.pathname, newFilename);
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject); ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return; return;
@ -594,176 +628,129 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
export const sortCollections = (payload) => (dispatch) => { export const sortCollections = (payload) => (dispatch) => {
dispatch(_sortCollections(payload)); dispatch(_sortCollections(payload));
}; };
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
.then(resolve)
.catch(reject);
});
}
export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType }) => {
const { pathname: targetItemPathname } = targetItem;
const { filename: draggedItemFilename } = draggedItem;
const targetItemDirname = path.dirname(targetItemPathname);
const isTargetTheCollection = targetItemPathname === collection.pathname;
const isTargetItemAFolder = isItemAFolder(targetItem);
if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
return path.join(targetItemPathname, draggedItemFilename)
} else if (dropType === 'adjacent') {
return path.join(targetItemDirname, draggedItemFilename)
}
return null;
};
const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
const { uid: targetItemUid } = targetItem;
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
const newDirname = path.dirname(newPathname);
await dispatch(moveItem({
targetDirname: newDirname,
sourcePathname: draggedItemPathname
}));
// Update sequences in the source directory
if (draggedItemDirectoryItems?.length) {
// reorder items in the source directory
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
if (reorderedSourceItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
}
}
// Update sequences in the target directory (if dropping adjacent)
if (dropType === 'adjacent') {
const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
const draggedItemWithNewPathAndSequence = {
...draggedItem,
pathname: newPathname,
seq: targetItemSequence
};
// draggedItem is added to the targetItem's directory
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
targetItemUid,
draggedItemUid
});
if (reorderedTargetItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
}
}
};
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
const { uid: targetItemUid } = targetItem;
const { uid: draggedItemUid } = draggedItem;
// reorder items in the targetItem's directory
const reorderedItems = getReorderedItemsInTargetDirectory({
items: targetItemDirectoryItems,
targetItemUid,
draggedItemUid
});
if (reorderedItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
}
};
return new Promise(async (resolve, reject) => {
try {
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType });
if (!newPathname) return;
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
if (newPathname !== draggedItemPathname) {
await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
} else {
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
}
resolve();
} catch (error) {
console.error(error);
toast.error(error?.message);
reject(error);
}
})
}
export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!collection) { const { ipcRenderer } = window;
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection); ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid); .then(resolve)
const targetItem = findItemInCollection(collectionCopy, targetItemUid); .catch(reject);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
if (!targetItem) {
return reject(new Error('Target item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
const sameParent = draggedItemParent === targetItemParent;
// file item dragged onto another file item and both are in the same folder
// this is also true when both items are at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item which is at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item and both are in different folders
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged into its own folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// file item dragged into another folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// end of the file drags, now let's handle folder drags
// folder drags are simpler since we don't allow ordering of folders
// folder dragged into its own folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// folder dragged into a file which is at the same level
// this is also true when both items are at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
return resolve();
}
// folder dragged into a file which is a child of the folder
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
return resolve();
}
// folder dragged into a file which is at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
}
// folder dragged into another folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
.then(resolve)
.catch((error) => reject(error));
}
}); });
}; }
export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
// file item is already at the root level
if (!draggedItemParent) {
return resolve();
}
const draggedItemPathname = draggedItem.pathname;
moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
if (isItemAFolder(draggedItem)) {
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
} else {
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
});
};
export const newHttpRequest = (params) => (dispatch, getState) => { export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params; const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
@ -823,8 +810,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
collection.items, collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
); );
const requestItems = filter(collection.items, (i) => i.type !== 'folder'); const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = requestItems.length + 1; item.seq = items.length + 1;
if (!reqWithSameNameExists) { if (!reqWithSameNameExists) {
const fullName = path.join(collection.pathname, resolvedFilename); const fullName = path.join(collection.pathname, resolvedFilename);
@ -852,8 +839,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
currentItem.items, currentItem.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
); );
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder'); const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = requestItems.length + 1; item.seq = items.length + 1;
if (!reqWithSameNameExists) { if (!reqWithSameNameExists) {
const fullName = path.join(currentItem.pathname, resolvedFilename); const fullName = path.join(currentItem.pathname, resolvedFilename);
const { ipcRenderer } = window; const { ipcRenderer } = window;

View File

@ -1719,6 +1719,9 @@ export const collectionsSlice = createSlice({
folderItem.name = file?.data?.meta?.name; folderItem.name = file?.data?.meta?.name;
} }
folderItem.root = file.data; folderItem.root = file.data;
if (file?.data?.meta?.seq) {
folderItem.seq = file.data?.meta?.seq;
}
} }
return; return;
} }
@ -1798,6 +1801,7 @@ export const collectionsSlice = createSlice({
uid: uuid(), uid: uuid(),
pathname: currentPath, pathname: currentPath,
name: dir?.meta?.name || directoryName, name: dir?.meta?.name || directoryName,
seq: dir?.meta?.seq || 1,
filename: directoryName, filename: directoryName,
collapsed: true, collapsed: true,
type: 'folder', type: 'folder',
@ -1829,6 +1833,9 @@ export const collectionsSlice = createSlice({
if (file?.data?.meta?.name) { if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name; folderItem.name = file?.data?.meta?.name;
} }
if (file?.data?.meta?.seq) {
folderItem.seq = file?.data?.meta?.seq;
}
folderItem.root = file.data; folderItem.root = file.data;
} }
return; return;

View File

@ -0,0 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
export const isTabForItemActive = ({ itemUid }) => createSelector([
(state) => state.tabs?.activeTabUid
], (activeTabUid) => activeTabUid === itemUid);
export const isTabForItemPresent = ({ itemUid }) => createSelector([
(state) => state.tabs.tabs,
], (tabs) => tabs.some((tab) => tab.uid === itemUid));

View File

@ -281,6 +281,12 @@ const darkTheme = {
color: 'rgb(52 51 49)' color: 'rgb(52 51 49)'
}, },
dragAndDrop: {
border: '#666666',
borderStyle: '2px solid',
hoverBg: 'rgba(102, 102, 102, 0.08)',
transition: 'all 0.1s ease'
},
infoTip: { infoTip: {
bg: '#1f1f1f', bg: '#1f1f1f',
border: '#333333', border: '#333333',

View File

@ -282,6 +282,12 @@ const lightTheme = {
color: 'rgb(152 151 149)' color: 'rgb(152 151 149)'
}, },
dragAndDrop: {
border: '#8b8b8b', // Using the same gray as focusBorder from input
borderStyle: '2px solid',
hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
transition: 'all 0.1s ease'
},
infoTip: { infoTip: {
bg: 'white', bg: 'white',
border: '#e0e0e0', border: '#e0e0e0',

View File

@ -98,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname); return findItemByPathname(flattenedItems, pathname);
}; };
export const findParentItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
export const findItemInCollection = (collection, itemUid) => { export const findItemInCollection = (collection, itemUid) => {
let flattenedItems = flattenItems(collection.items); let flattenedItems = flattenItems(collection.items);
@ -150,90 +158,6 @@ export const getItemsLoadStats = (folder) => {
}; };
} }
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
if (targetItem.type === 'folder') {
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
targetItem.items.push(draggedItem);
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
}
};
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
// If the dragged item is already at the root of the collection, do nothing
if (!draggedItemParent) {
return;
}
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items.push(draggedItem);
if (draggedItem.type == 'folder') {
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
} else {
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
};
export const getItemsToResequence = (parent, collection) => {
let itemsToResequence = [];
if (!parent) {
let index = 1;
each(collection.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
if (parent.items && parent.items.length) {
let index = 1;
each(parent.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
return itemsToResequence;
};
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => { export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => { const copyHeaders = (headers) => {
return map(headers, (header) => { return map(headers, (header) => {
@ -502,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (meta?.name) { if (meta?.name) {
di.root.meta = {}; di.root.meta = {};
di.root.meta.name = meta?.name; di.root.meta.name = meta?.name;
di.root.meta.seq = meta?.seq;
} }
if (!Object.keys(di.root.request)?.length) { if (!Object.keys(di.root.request)?.length) {
delete di.root.request; delete di.root.request;
@ -1086,3 +1011,62 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = []
}); });
return credentialsVariables; return credentialsVariables;
}; };
// item sequence utils - START
export const resetSequencesInFolder = (folderItems) => {
const items = folderItems;
const sortedItems = items.sort((a, b) => a.seq - b.seq);
return sortedItems.map((item, index) => {
item.seq = index + 1;
return item;
});
};
export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
if (targetItemSequence > sourceItemSequence) {
return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
}
return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
};
export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
if (!isDraggedItem) {
return null;
}
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
};
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
const targetSequence = targetItem?.seq;
const draggedSequence = draggedItem?.seq;
itemsWithFixedSequences?.forEach(item => {
const isDraggedItem = item?.uid === draggedItemUid;
const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
if (isBetween) {
item.seq += targetSequence > draggedSequence ? -1 : 1;
}
const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
if (newSequence !== null) {
item.seq = newSequence;
}
});
// only return items that have been reordered
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const getReorderedItemsInSourceDirectory = ({ items }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
// item sequence utils - END

View File

@ -0,0 +1,126 @@
import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index';
describe('resetSequencesInFolder', () => {
it('should fix the sequences in the folder 1', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 6 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
]);
});
it('should fix the sequences in the folder 2', () => {
const folder = {
items: [
{ uid: '1', seq: 3 },
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
{ uid: '1', seq: 3 },
]);
});
it('should fix the sequences in the folder with missing sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', type: 'folder' },
{ uid: '3', type: 'folder' },
{ uid: '4', seq: 7 },
]
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2, type: 'folder' },
{ uid: '3', seq: 3, type: 'folder' },
{ uid: '4', seq: 4 },
]);
});
it('should fix the sequences in the folder with same sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 2 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
{ uid: '4', seq: 1 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '4', seq: 1 },
{ uid: '1', seq: 2 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 4 },
]);
});
});
describe('isItemBetweenSequences', () => {
it('should return true if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 3', () => {
const item = { uid: '1', seq: 4 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 4', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 5;
const targetSequence = 1;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return false if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
it('should return false if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 5 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
});

View File

@ -252,7 +252,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
const requestMap = {}; const requestMap = {};
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'] const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
each(item, (i) => { each(item, (i, index) => {
if (isItemAFolder(i)) { if (isItemAFolder(i)) {
const baseFolderName = i.name || 'Untitled Folder'; const baseFolderName = i.name || 'Untitled Folder';
let folderName = baseFolderName; let folderName = baseFolderName;
@ -268,6 +268,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
name: folderName, name: folderName,
type: 'folder', type: 'folder',
items: [], items: [],
seq: index + 1,
root: { root: {
docs: i.description || '', docs: i.description || '',
meta: { meta: {
@ -332,6 +333,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
uid: uuid(), uid: uuid(),
name: requestName, name: requestName,
type: 'http-request', type: 'http-request',
seq: index + 1,
request: { request: {
url: url, url: url,
method: i?.request?.method?.toUpperCase(), method: i?.request?.method?.toUpperCase(),

View File

@ -73,92 +73,93 @@ const expectedOutput = {
"version": "1", "version": "1",
"items": [ "items": [
{ {
"uid": "mockeduuidvalue123456", "uid": "mockeduuidvalue123456",
"name": "folder", "name": "folder",
"type": "folder", "type": "folder",
"items": [ "seq": 1,
{ "items": [
"uid": "mockeduuidvalue123456", {
"name": "request", "uid": "mockeduuidvalue123456",
"type": "http-request", "name": "request",
"request": { "type": "http-request",
"url": "https://usebruno.com", "seq": 1,
"method": "GET", "request": {
"auth": { "url": "https://usebruno.com",
"mode": "none", "method": "GET",
"basic": null, "auth": {
"bearer": null, "mode": "none",
"awsv4": null, "basic": null,
"apikey": null, "bearer": null,
"oauth2": null, "awsv4": null,
"digest": null "apikey": null,
}, "oauth2": null,
"headers": [], "digest": null
"params": [], },
"body": { "headers": [],
"mode": "none", "params": [],
"json": null, "body": {
"text": null, "mode": "none",
"xml": null, "json": null,
"formUrlEncoded": [], "text": null,
"multipartForm": [] "xml": null,
}, "formUrlEncoded": [],
"docs": "" "multipartForm": []
}, },
"seq": 1 "docs": ""
} }
], }
"root": { ],
"docs": "", "root": {
"meta": { "docs": "",
"name": "folder" "meta": {
}, "name": "folder"
"request": { },
"auth": { "request": {
"mode": "none", "auth": {
"basic": null, "mode": "none",
"bearer": null, "basic": null,
"awsv4": null, "bearer": null,
"apikey": null, "awsv4": null,
"oauth2": null, "apikey": null,
"digest": null "oauth2": null,
}, "digest": null
"headers": [], },
"script": {}, "headers": [],
"tests": "", "script": {},
"vars": {} "tests": "",
} "vars": {}
} }
}
}, },
{ {
"uid": "mockeduuidvalue123456", "uid": "mockeduuidvalue123456",
"name": "request", "name": "request",
"type": "http-request", "type": "http-request",
"request": { "seq": 2,
"url": "https://usebruno.com", "request": {
"method": "GET", "url": "https://usebruno.com",
"auth": { "method": "GET",
"mode": "none", "auth": {
"basic": null, "mode": "none",
"bearer": null, "basic": null,
"awsv4": null, "bearer": null,
"apikey": null, "awsv4": null,
"oauth2": null, "apikey": null,
"digest": null "oauth2": null,
}, "digest": null
"headers": [], },
"params": [], "headers": [],
"body": { "params": [],
"mode": "none", "body": {
"json": null, "mode": "none",
"text": null, "json": null,
"xml": null, "text": null,
"formUrlEncoded": [], "xml": null,
"multipartForm": [] "formUrlEncoded": [],
}, "multipartForm": []
"docs": "" },
}, "docs": ""
"seq": 1 },
} }
], ],
"environments": [], "environments": [],

View File

@ -220,7 +220,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
} }
} }
// Is this a folder.bru file?
if (path.basename(pathname) === 'folder.bru') { if (path.basename(pathname) === 'folder.bru') {
const file = { const file = {
meta: { meta: {
@ -327,16 +326,25 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
} }
let name = path.basename(pathname); let name = path.basename(pathname);
let seq = 1;
const folderBruFilePath = path.join(pathname, `folder.bru`);
if (fs.existsSync(folderBruFilePath)) {
let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruData = await collectionBruToJson(folderBruFileContent);
name = folderBruData?.meta?.name || name;
seq = folderBruData?.meta?.seq || seq;
}
const directory = { const directory = {
meta: { meta: {
collectionUid, collectionUid,
pathname, pathname,
name name,
seq
} }
}; };
win.webContents.send('main:collection-tree-updated', 'addDir', directory); win.webContents.send('main:collection-tree-updated', 'addDir', directory);
}; };

View File

@ -29,9 +29,11 @@ const collectionBruToJson = async (data, parsed = false) => {
// add meta if it exists // add meta if it exists
// this is only for folder bru file // this is only for folder bru file
// in the future, all of this will be replaced by standard bru lang // in the future, all of this will be replaced by standard bru lang
if (json.meta) { const sequence = _.get(json, 'meta.seq');
if (json?.meta) {
transformedJson.meta = { transformedJson.meta = {
name: json.meta.name name: json.meta.name,
seq: !isNaN(sequence) ? Number(sequence) : 1
}; };
} }
@ -61,9 +63,11 @@ const jsonToCollectionBru = async (json, isFolder) => {
// add meta if it exists // add meta if it exists
// this is only for folder bru file // this is only for folder bru file
// in the future, all of this will be replaced by standard bru lang // in the future, all of this will be replaced by standard bru lang
const sequence = _.get(json, 'meta.seq');
if (json?.meta) { if (json?.meta) {
collectionBruJson.meta = { collectionBruJson.meta = {
name: json.meta.name name: json.meta.name,
seq: !isNaN(sequence) ? Number(sequence) : 1
}; };
} }

View File

@ -1,5 +1,6 @@
const _ = require('lodash'); const _ = require('lodash');
const fs = require('fs'); const fs = require('fs');
const fsPromises = require('fs/promises');
const fsExtra = require('fs-extra'); const fsExtra = require('fs-extra');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
@ -22,7 +23,9 @@ const {
hasSubDirectories, hasSubDirectories,
getCollectionStats, getCollectionStats,
sizeInMB, sizeInMB,
safeWriteFileSync safeWriteFileSync,
copyPath,
removePath
} = require('../utils/filesystem'); } = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections'); const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common'); const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@ -32,11 +35,10 @@ const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security'); const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshotStore = require('../store/ui-state-snapshot'); const UiStateSnapshotStore = require('../store/ui-state-snapshot');
const interpolateVars = require('./network/interpolate-vars'); const interpolateVars = require('./network/interpolate-vars');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars } = require('../utils/collection'); const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env'); const { getProcessEnvVars } = require('../store/process-env');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, refreshOauth2Token } = require('../utils/oauth2'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, refreshOauth2Token } = require('../utils/oauth2');
const { getCertsAndProxyConfig } = require('./network'); const { getCertsAndProxyConfig } = require('./network');
const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const environmentSecretsStore = new EnvironmentSecretsStore(); const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore(); const collectionSecurityStore = new CollectionSecurityStore();
@ -192,12 +194,15 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:save-folder-root', async (event, folder) => { ipcMain.handle('renderer:save-folder-root', async (event, folder) => {
try { try {
const { name: folderName, root: folderRoot, pathname: folderPathname } = folder; const { name: folderName, root: folderRoot = {}, pathname: folderPathname } = folder;
const folderBruFilePath = path.join(folderPathname, 'folder.bru'); const folderBruFilePath = path.join(folderPathname, 'folder.bru');
folderRoot.meta = { if (!folderRoot.meta) {
name: folderName folderRoot.meta = {
}; name: folderName,
seq: 1
};
}
const content = await jsonToCollectionBru( const content = await jsonToCollectionBru(
folderRoot, folderRoot,
@ -376,14 +381,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(folderBruFilePath)) { if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8'); const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent); folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
folderBruFileJsonContent.meta.name = newName;
} else { } else {
folderBruFileJsonContent = {}; folderBruFileJsonContent = {
meta: {
name: newName,
seq: 1
}
};
} }
folderBruFileJsonContent.meta = {
name: newName,
};
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true); const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
await writeFile(folderBruFilePath, folderBruFileContent); await writeFile(folderBruFilePath, folderBruFileContent);
@ -425,14 +432,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(folderBruFilePath)) { if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8'); const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent); folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
folderBruFileJsonContent.meta.name = newName;
} else { } else {
folderBruFileJsonContent = {}; folderBruFileJsonContent = {
meta: {
name: newName,
seq: 1
}
};
} }
folderBruFileJsonContent.meta = {
name: newName,
};
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true); const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
await writeFile(folderBruFilePath, folderBruFileContent); await writeFile(folderBruFilePath, folderBruFileContent);
@ -512,6 +521,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
let data = { let data = {
meta: { meta: {
name: folderName, name: folderName,
seq: 1
} }
}; };
const content = await jsonToCollectionBru(data, true); // isFolder flag const content = await jsonToCollectionBru(data, true); // isFolder flag
@ -598,6 +608,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (item?.root?.meta?.name) { if (item?.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru'); const folderBruFilePath = path.join(folderPath, 'folder.bru');
item.root.meta.seq = item.seq;
const folderContent = await jsonToCollectionBru( const folderContent = await jsonToCollectionBru(
item.root, item.root,
true // isFolder true // isFolder
@ -731,17 +742,42 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => { ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => {
try { try {
for await (let item of itemsToResequence) { for (let item of itemsToResequence) {
const bru = fs.readFileSync(item.pathname, 'utf8'); if (item?.type === 'folder') {
const jsonData = await bruToJsonViaWorker(bru); const folderRootPath = path.join(item.pathname, 'folder.bru');
let folderBruJsonData = {
if (jsonData.seq !== item.seq) { meta: {
jsonData.seq = item.seq; name: path.basename(item?.pathname),
const content = await jsonToBruViaWorker(jsonData); seq: item?.seq || 1
await writeFile(item.pathname, content); }
};
if (fs.existsSync(folderRootPath)) {
const bru = fs.readFileSync(folderRootPath, 'utf8');
folderBruJsonData = await collectionBruToJson(bru);
if (!folderBruJsonData?.meta) {
folderBruJsonData.meta = {
name: path.basename(item?.pathname),
seq: item?.seq || 1
};
}
if (folderBruJsonData?.meta?.seq === item.seq) {
continue;
}
folderBruJsonData.meta.seq = item.seq;
}
const content = await jsonToCollectionBru(folderBruJsonData);
await writeFile(folderRootPath, content);
} else {
if (fs.existsSync(item.pathname)) {
const itemToSave = transformRequestToSaveToFilesystem(item);
const content = await jsonToBruViaWorker(itemToSave);
await writeFile(item.pathname, content);
}
} }
} }
return true;
} catch (error) { } catch (error) {
console.error('Error in resequence-items:', error);
return Promise.reject(error); return Promise.reject(error);
} }
}); });
@ -760,6 +796,17 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
} }
}); });
ipcMain.handle('renderer:move-item', async (event, { targetDirname, sourcePathname }) => {
try {
if (fs.existsSync(targetDirname)) {
await copyPath(sourcePathname, targetDirname);
await removePath(sourcePathname);
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:move-folder-item', async (event, folderPath, destinationPath) => { ipcMain.handle('renderer:move-folder-item', async (event, folderPath, destinationPath) => {
try { try {
const folderName = path.basename(folderPath); const folderName = path.basename(folderPath);

View File

@ -1255,6 +1255,7 @@ const registerNetworkIpc = (mainWindow) => {
folderUid folderUid
}); });
} catch (error) { } catch (error) {
console.log("error", error);
deleteCancelToken(cancelTokenUid); deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', { mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended', type: 'testrun-ended',

View File

@ -1,4 +1,4 @@
const { get, each, find, compact, filter } = require('lodash'); const { get, each, find, compact, isString, filter } = require('lodash');
const fs = require('fs'); const fs = require('fs');
const { getRequestUid } = require('../cache/requestUids'); const { getRequestUid } = require('../cache/requestUids');
const { uuid } = require('./common'); const { uuid } = require('./common');
@ -205,6 +205,14 @@ const findParentItemInCollection = (collection, itemUid) => {
}); });
}; };
const findParentItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
const getTreePathFromCollectionToItem = (collection, _item) => { const getTreePathFromCollectionToItem = (collection, _item) => {
let path = []; let path = [];
let item = findItemInCollection(collection, _item.uid); let item = findItemInCollection(collection, _item.uid);
@ -272,12 +280,73 @@ const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname); return findItemByPathname(flattenedItems, pathname);
}; };
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
return '';
}
return str.replaceAll('\t', ' '.repeat(numSpaces));
};
const transformRequestToSaveToFilesystem = (item) => {
const _item = item.draft ? item.draft : item;
const itemToSave = {
uid: _item.uid,
type: _item.type,
name: _item.name,
seq: _item.seq,
request: {
method: _item.request.method,
url: _item.request.url,
params: [],
headers: [],
auth: _item.request.auth,
body: _item.request.body,
script: _item.request.script,
vars: _item.request.vars,
assertions: _item.request.assertions,
tests: _item.request.tests,
docs: _item.request.docs
}
};
each(_item.request.params, (param) => {
itemToSave.request.params.push({
uid: param.uid,
name: param.name,
value: param.value,
description: param.description,
type: param.type,
enabled: param.enabled
});
});
each(_item.request.headers, (header) => {
itemToSave.request.headers.push({
uid: header.uid,
name: header.name,
value: header.value,
description: header.description,
enabled: header.enabled
});
});
if (itemToSave.request.body.mode === 'json') {
itemToSave.request.body = {
...itemToSave.request.body,
json: replaceTabsWithSpaces(itemToSave.request.body.json)
};
}
return itemToSave;
}
const sortCollection = (collection) => { const sortCollection = (collection) => {
const items = collection.items || []; const items = collection.items || [];
let folderItems = filter(items, (item) => item.type === 'folder'); let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder'); let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name)); folderItems = folderItems.sort((a, b) => a.seq - b.seq);
requestItems = requestItems.sort((a, b) => a.seq - b.seq); requestItems = requestItems.sort((a, b) => a.seq - b.seq);
collection.items = folderItems.concat(requestItems); collection.items = folderItems.concat(requestItems);
@ -292,7 +361,7 @@ const sortFolder = (folder = {}) => {
let folderItems = filter(items, (item) => item.type === 'folder'); let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder'); let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name)); folderItems = folderItems.sort((a, b) => a.seq - b.seq);
requestItems = requestItems.sort((a, b) => a.seq - b.seq); requestItems = requestItems.sort((a, b) => a.seq - b.seq);
folder.items = folderItems.concat(requestItems); folder.items = folderItems.concat(requestItems);
@ -410,11 +479,13 @@ module.exports = {
findItemByPathname, findItemByPathname,
findItemInCollectionByPathname, findItemInCollectionByPathname,
findParentItemInCollection, findParentItemInCollection,
findParentItemInCollectionByPathname,
parseBruFileMeta, parseBruFileMeta,
hydrateRequestWithUuid,
transformRequestToSaveToFilesystem,
sortCollection, sortCollection,
sortFolder, sortFolder,
getAllRequestsInFolderRecursively, getAllRequestsInFolderRecursively,
getEnvVars, getEnvVars,
getFormattedCollectionOauth2Credentials, getFormattedCollectionOauth2Credentials
hydrateRequestWithUuid
}; };

View File

@ -282,6 +282,48 @@ function safeWriteFileSync(filePath, data) {
fs.writeFileSync(safePath, data); fs.writeFileSync(safePath, data);
} }
// Recursively copies a source <file/directory> to a destination <directory>.
const copyPath = async (source, destination) => {
let targetPath = `${destination}/${path.basename(source)}`;
const targetPathExists = await fsPromises.access(targetPath).then(() => true).catch(() => false);
if (targetPathExists) {
throw new Error(`Cannot copy, ${path.basename(source)} already exists in ${path.basename(destination)}`);
}
const copy = async (source, destination) => {
const stat = await fsPromises.lstat(source);
if (stat.isDirectory()) {
await fsPromises.mkdir(destination, { recursive: true });
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const srcPath = path.join(source, entry);
const destPath = path.join(destination, entry);
await copy(srcPath, destPath);
}
} else {
await fsPromises.copyFile(source, destination);
}
}
await copy(source, targetPath);
}
// Recursively removes a source <file/directory>.
const removePath = async (source) => {
const stat = await fsPromises.lstat(source);
if (stat.isDirectory()) {
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const entryPath = path.join(source, entry);
await removePath(entryPath);
}
await fsPromises.rmdir(source);
} else {
await fsPromises.unlink(source);
}
}
module.exports = { module.exports = {
isValidPathname, isValidPathname,
exists, exists,
@ -308,5 +350,7 @@ module.exports = {
getCollectionStats, getCollectionStats,
sizeInMB, sizeInMB,
safeWriteFile, safeWriteFile,
safeWriteFileSync safeWriteFileSync,
copyPath,
removePath
}; };

View File

@ -0,0 +1,116 @@
const path = require('path');
const fs = require('fs/promises');
const os = require('os');
const { copyPath, removePath } = require('../../filesystem');
const { initialCollectionStructure, finalCollectionStructure } = require('../fixtures/filesystem/copypath-removepath');
describe('File System Operations', () => {
let tempDir;
beforeAll(async () => {
// Create a temporary directory for each test
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bruno-test-'));
await createFilesAndFolders(tempDir, initialCollectionStructure);
const result = await verifyFilesAndFolders(tempDir, initialCollectionStructure);
expect(result).toBe(true);
});
afterAll(async () => {
// clean up after each test
await fs.rm(tempDir, { recursive: true, force: true });
// confirm the temp directory is deleted
expect(await fs.access(tempDir).then(() => true).catch(() => false)).toBe(false);
});
describe('copyPath and removePath', () => {
it('should move files and folder items multiple times', async () => {
{
const sourcePath = path.join(tempDir, 'folder_1', 'file_2.bru');
const destDir = path.join(tempDir, 'folder_1', 'folder_1_1');
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
{
const sourcePath = path.join(tempDir, 'folder_2');
const destDir = path.join(tempDir, 'folder_1', 'folder_1_1');
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
{
const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'file_2_2.bru');
const destDir = path.join(tempDir, 'folder_1');
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
{
const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'folder_2_1');
const destDir = path.join(tempDir);
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
const result = await verifyFilesAndFolders(tempDir, finalCollectionStructure);
expect(result).toBe(true);
});
it('should throw an error move file/folder if the destination has the same filename', async () => {
{
const sourcePath = path.join(tempDir, 'folder_1', 'file_dup.bru');
const destDir = path.join(tempDir, 'folder_1');
await expect(copyPath(sourcePath, destDir)).rejects.toThrow();
}
});
});
});
// create folders and files recursively based on the defined json structure
const createFilesAndFolders = async (dir, filesAndFolders) => {
for (const item of filesAndFolders) {
const itemPath = path.join(dir, item.name);
if (item.type === 'folder') {
await fs.mkdir(itemPath, { recursive: true });
await createFilesAndFolders(itemPath, item.files);
} else {
await fs.writeFile(itemPath, item.content);
}
}
}
// if a file/folder doesnt exist, return false
// should only contain files and folders that are defined in the json structure
const verifyFilesAndFolders = async (dir, filesAndFolders) => {
const verify = async (dir, filesAndFolders) => {
const files = await fs.readdir(dir);
if (files.length !== filesAndFolders.length) {
return false;
}
for (const file of files) {
const itemPath = path.join(dir, file);
const item = filesAndFolders.find(f => f.name === file);
if (!item) {
return false;
}
if (item.type === 'folder') {
return await verify(itemPath, item.files);
} else {
return await fs.readFile(itemPath, 'utf8').then(content => content === item.content);
}
}
return true;
}
try {
const verified = await verify(dir, filesAndFolders);
return verified;
} catch (error) {
console.error(error);
return false;
}
}

View File

@ -0,0 +1,155 @@
const initialCollectionStructure = [
{
"name": "folder_1",
"type": "folder",
"files": [
{
"name": "file_1.bru",
"type": "file",
"content": "file_1_content"
},
{
"name": "file_2.bru",
"type": "file",
"content": "file_2_content"
},
{
"name": "folder_1_1",
"type": "folder",
"files": [
{
"name": "file_1_1.bru",
"type": "file",
"content": "file_1_1_content"
},
{
"name": "file_1_2.bru",
"type": "file",
"content": "file_1_2_content"
}
]
},
{
"name": "file_1_3.bru",
"type": "file",
"content": "file_1_3_content"
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
],
},
{
"name": "folder_2",
"type": "folder",
"files": [
{
"name": "file_2_1.bru",
"type": "file",
"content": "file_2_1_content"
},
{
"name": "file_2_2.bru",
"type": "file",
"content": "file_2_2_content"
},
{
"name": "folder_2_1",
"type": "folder",
"files": [
{
"name": "file_2_1_1.bru",
"type": "file",
"content": "file_2_1_1_content"
}
]
}
]
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
];
const finalCollectionStructure = [
{
"name": "folder_1",
"type": "folder",
"files": [
{
"name": "file_1.bru",
"type": "file",
"content": "file_1_content"
},
{
"name": "folder_1_1",
"type": "folder",
"files": [
{
"name": "file_1_1.bru",
"type": "file",
"content": "file_1_1_content"
},
{
"name": "file_1_2.bru",
"type": "file",
"content": "file_1_2_content"
},
{
"name": "file_2.bru",
"type": "file",
"content": "file_2_content"
},
{
"name": "folder_2",
"type": "folder",
"files": [
{
"name": "file_2_1.bru",
"type": "file",
"content": "file_2_1_content"
}
]
}
]
},
{
"name": "file_1_3.bru",
"type": "file",
"content": "file_1_3_content"
},
{
"name": "file_2_2.bru",
"type": "file",
"content": "file_2_2_content"
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
],
},
{
"name": "folder_2_1",
"type": "folder",
"files": [
{
"name": "file_2_1_1.bru",
"type": "file",
"content": "file_2_1_1_content"
}
]
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
];
module.exports = { initialCollectionStructure, finalCollectionStructure };

View File

@ -340,7 +340,8 @@ const folderRootSchema = Yup.object({
.nullable(), .nullable(),
docs: Yup.string().nullable(), docs: Yup.string().nullable(),
meta: Yup.object({ meta: Yup.object({
name: Yup.string().nullable() name: Yup.string().nullable(),
seq: Yup.number().min(1).nullable()
}) })
.noUnknown(true) .noUnknown(true)
.strict() .strict()