feat: drag and drop for files and folders

This commit is contained in:
Anoop M D 2023-01-20 00:45:07 +05:30
parent c4abe54c3f
commit 21c9c8b4fb
15 changed files with 534 additions and 198 deletions

View File

@ -1,9 +1,10 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
const RequestNotFound = ({ itemUid }) => {
const dispatch = useDispatch();
const [showErrorMessage, setShowErrorMessage] = useState(false);
const closeTab = () => {
dispatch(
@ -13,6 +14,20 @@ const RequestNotFound = ({ itemUid }) => {
);
};
useEffect(() => {
setTimeout(() => {
setShowErrorMessage(true);
}, 300);
}, []);
// add a delay component in react that shows a loading spinner
// and then shows the error message after a delay
// this will prevent the error message from flashing on the screen
if(!showErrorMessage) {
return null;
}
return (
<div className="mt-6 px-6">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700 bg-yellow-100 p-4">

View File

@ -0,0 +1,42 @@
import React, { useState, useEffect } from 'react';
import { IconAlertTriangle } from '@tabler/icons';
const RequestTabNotFound = ({handleCloseClick}) => {
const [showErrorMessage, setShowErrorMessage] = useState(false);
// add a delay component in react that shows a loading spinner
// and then shows the error message after a delay
// this will prevent the error message from flashing on the screen
useEffect(() => {
setTimeout(() => {
setShowErrorMessage(true);
}, 300);
}, []);
if(!showErrorMessage) {
return null;
}
return (
<>
<div className="flex items-center tab-label pl-2">
{showErrorMessage ? (
<>
<IconAlertTriangle size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Not Found</span>
</>
) : null}
</div>
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon">
<path
fill="currentColor"
d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"
></path>
</svg>
</div>
</>
);
};
export default RequestTabNotFound;

View File

@ -4,7 +4,7 @@ import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import { findItemInCollection } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { IconAlertTriangle } from '@tabler/icons';
import RequestTabNotFound from './RequestTabNotFound';
const RequestTab = ({ tab, collection }) => {
const dispatch = useDispatch();
@ -61,18 +61,7 @@ const RequestTab = ({ tab, collection }) => {
if (!item) {
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
<div className="flex items-center tab-label pl-2">
<IconAlertTriangle size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Not Found</span>
</div>
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon">
<path
fill="currentColor"
d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"
></path>
</svg>
</div>
<RequestTabNotFound handleCloseClick={handleCloseClick} />
</StyledWrapper>
);
}

View File

@ -35,28 +35,28 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed);
// const [{ isDragging }, drag] = useDrag({
// type: 'COLLECTION_ITEM',
// item: item,
// collect: (monitor) => ({
// isDragging: monitor.isDragging()
// })
// });
const [{ isDragging }, drag] = useDrag({
type: 'COLLECTION_ITEM',
item: item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
});
// const [{ isOver }, drop] = useDrop({
// accept: 'COLLECTION_ITEM',
// drop: (draggedItem) => {
// if (draggedItem.uid !== item.uid) {
// dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
// }
// },
// canDrop: (draggedItem) => {
// return draggedItem.uid !== item.uid;
// },
// collect: (monitor) => ({
// isOver: monitor.isOver()
// })
// });
const [{ isOver }, drop] = useDrop({
accept: 'COLLECTION_ITEM',
drop: (draggedItem) => {
if (draggedItem.uid !== item.uid) {
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
}
},
canDrop: (draggedItem) => {
return draggedItem.uid !== item.uid;
},
collect: (monitor) => ({
isOver: monitor.isOver()
})
});
useEffect(() => {
if (searchText && searchText.length) {
@ -131,8 +131,18 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
}
const requestItems = filter(item.items, (i) => isItemARequest(i));
const folderItems = filter(item.items, (i) => isItemAFolder(i));
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
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 requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
return (
<StyledWrapper className={className}>
@ -141,7 +151,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
{deleteItemModalOpen && <DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />}
{newRequestModalOpen && <NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />}
{newFolderModalOpen && <NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />}
<div className={itemRowClassName}>
<div className={itemRowClassName} ref={(node) => drag(drop(node))}>
<div className="flex items-center h-full w-full">
{indents && indents.length
? indents.map((i) => {
@ -239,13 +249,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
{!itemIsCollapsed ? (
<div>
{requestItems && requestItems.length
? requestItems.map((i) => {
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{folderItems && folderItems.length
? folderItems.map((i) => {
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}

View File

@ -59,28 +59,37 @@ const Collection = ({ collection, searchText }) => {
}
}
const requestItems = filter(collection.items, (i) => isItemARequest(i));
const folderItems = filter(collection.items, (i) => isItemAFolder(i));
const handleExportClick = () => {
const collectionCopy = cloneDeep(collection);
exportCollection(transformCollectionToSaveToIdb(collectionCopy));
};
// const [{ isOver }, drop] = useDrop({
// accept: 'COLLECTION_ITEM',
// drop: (draggedItem) => {
// console.log('drop', draggedItem);
// dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid));
// },
// canDrop: (draggedItem) => {
// // todo need to make sure that draggedItem belongs to the collection
// return true;
// },
// collect: (monitor) => ({
// isOver: monitor.isOver()
// })
// });
const [{ isOver }, drop] = useDrop({
accept: 'COLLECTION_ITEM',
drop: (draggedItem) => {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid));
},
canDrop: (draggedItem) => {
// todo need to make sure that draggedItem belongs to the collection
return true;
},
collect: (monitor) => ({
isOver: monitor.isOver()
})
});
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
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 requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
return (
<StyledWrapper className="flex flex-col">
@ -88,7 +97,7 @@ const Collection = ({ collection, searchText }) => {
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />}
{showRenameCollectionModal && <RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} />}
{showRemoveCollectionModal && <RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />}
<div className="flex py-1 collection-name items-center">
<div className="flex py-1 collection-name items-center" ref={drop}>
<div className="flex flex-grow items-center" onClick={handleClick}>
<IconChevronRight size={16} strokeWidth={2} className={iconClassName} style={{ width: 16, color: 'rgb(160 160 160)' }} />
<div className="ml-1" id="sidebar-collection-name">{collection.name}</div>
@ -148,14 +157,13 @@ const Collection = ({ collection, searchText }) => {
<div>
{!collectionIsCollapsed ? (
<div>
{requestItems && requestItems.length
? requestItems.map((i) => {
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{folderItems && folderItems.length
? folderItems.map((i) => {
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}

View File

@ -6,6 +6,7 @@ import cloneDeep from 'lodash/cloneDeep';
import {
findItemInCollection,
moveCollectionItem,
getItemsToResequence,
moveCollectionItemToRootOfCollection,
findCollectionByUid,
recursivelyGetAllItemUids,
@ -13,6 +14,7 @@ import {
transformRequestToSaveToFilesystem,
findParentItemInCollection,
findEnvironmentInCollection,
isItemARequest,
isItemAFolder,
refreshUidsInItem,
interpolateEnvironmentVars
@ -30,8 +32,6 @@ import {
renameItem as _renameItem,
cloneItem as _cloneItem,
deleteItem as _deleteItem,
moveItem as _moveItem,
moveItemToRootOfCollection as _moveItemToRootOfCollection,
saveRequest as _saveRequest,
selectEnvironment as _selectEnvironment,
createCollection as _createCollection,
@ -330,27 +330,6 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa
return reject(new Error('Collection not found'));
}
if (isLocalCollection(collection)) {
const draggedItem = findItemInCollection(collection, draggedItemUid);
const targetItem = findItemInCollection(collection, targetItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
if (!targetItem) {
return reject(new Error('Target item not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:move-item', draggedItem.pathname, targetItem.pathname)
.then(() => resolve())
.catch((error) => reject(error));
return;
}
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
const targetItem = findItemInCollection(collectionCopy, targetItemUid);
@ -363,24 +342,112 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa
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);
const collectionToSave = transformCollectionToSaveToIdb(collectionCopy);
collectionSchema
.validate(collectionToSave)
.then(() => saveCollectionToIdb(window.__idb, collectionToSave))
.then(() => {
dispatch(
_moveItem({
collectionUid: collectionUid,
draggedItemUid: draggedItemUid,
targetItemUid: targetItemUid
})
);
})
.then(() => resolve())
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);
console.log('itemsToResequence', itemsToResequence);
console.log('itemsToResequence2', itemsToResequence2);
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));
}
});
};
@ -393,45 +460,28 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di
return reject(new Error('Collection not found'));
}
if (isLocalCollection(collection)) {
const draggedItem = findItemInCollection(collection, draggedItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:move-item-to-root-of-collection', draggedItem.pathname)
.then(() => resolve())
.catch((error) => reject(error));
return;
}
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);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
const collectionToSave = transformCollectionToSaveToIdb(collectionCopy);
collectionSchema
.validate(collectionToSave)
.then(() => saveCollectionToIdb(window.__idb, collectionToSave))
.then(() => {
dispatch(
_moveItemToRootOfCollection({
collectionUid: collectionUid,
draggedItemUid: draggedItemUid
})
);
})
.then(() => resolve())
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));
});
};

View File

@ -14,10 +14,11 @@ import {
findEnvironmentInCollection,
findItemInCollectionByPathname,
addDepth,
moveCollectionItem,
collapseCollection,
deleteItemInCollection,
isItemARequest
deleteItemInCollectionByPathname,
isItemARequest,
areItemsTheSameExceptSeqUpdate
} from 'utils/collections';
import { parseQueryParams, stringifyQueryParams } from 'utils/url';
import { getSubdirectoriesFromRoot } from 'utils/common/platform';
@ -146,38 +147,6 @@ export const collectionsSlice = createSlice({
}
}
},
moveItem: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const draggedItemUid = action.payload.draggedItemUid;
const targetItemUid = action.payload.targetItemUid;
if (collection) {
const draggedItem = findItemInCollection(collection, draggedItemUid);
const targetItem = findItemInCollection(collection, targetItemUid);
if (!draggedItem || !targetItem) {
return;
}
moveCollectionItem(collection, draggedItem, targetItem);
addDepth(collection.items);
}
},
moveItemToRootOfCollection: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const draggedItemUid = action.payload.draggedItemUid;
if (collection) {
const draggedItem = findItemInCollection(collection, draggedItemUid);
if (!draggedItem) {
return;
}
moveCollectionItemToRootOfCollection(collection, draggedItem);
addDepth(collection.items);
}
},
requestSent: (state, action) => {
const { itemUid, collectionUid, cancelTokenUid, requestSent } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@ -650,7 +619,7 @@ export const collectionsSlice = createSlice({
uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
name: directoryName,
collapsed: false,
collapsed: true,
type: 'folder',
items: []
};
@ -669,6 +638,7 @@ export const collectionsSlice = createSlice({
if (currentItem) {
currentItem.name = file.data.name;
currentItem.type = file.data.type;
currentItem.seq = file.data.seq;
currentItem.request = file.data.request;
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
@ -678,6 +648,7 @@ export const collectionsSlice = createSlice({
uid: file.data.uid,
name: file.data.name,
type: file.data.type,
seq: file.data.seq,
request: file.data.request,
filename: file.meta.name,
pathname: file.meta.pathname,
@ -703,7 +674,7 @@ export const collectionsSlice = createSlice({
uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
name: directoryName,
collapsed: false,
collapsed: true,
type: 'folder',
items: []
};
@ -724,14 +695,22 @@ export const collectionsSlice = createSlice({
const item = findItemInCollection(collection, file.data.uid);
if (item) {
// whenever a user attempts to sort a req within the same folder
// the seq is updated, but everything else remains the same
// we don't want to lose the draft in this case
if(areItemsTheSameExceptSeqUpdate(item, file.data)) {
item.seq = file.data.seq;
} else {
item.name = file.data.name;
item.type = file.data.type;
item.seq = file.data.seq;
item.request = file.data.request;
item.filename = file.meta.name;
item.pathname = file.meta.pathname;
item.draft = null;
}
}
}
},
collectionUnlinkFileEvent: (state, action) => {
const { file } = action.payload;
@ -741,7 +720,7 @@ export const collectionsSlice = createSlice({
const item = findItemInCollectionByPathname(collection, file.meta.pathname);
if (item) {
deleteItemInCollection(item.uid, collection);
deleteItemInCollectionByPathname(file.meta.pathname, collection);
}
}
},
@ -753,7 +732,7 @@ export const collectionsSlice = createSlice({
const item = findItemInCollectionByPathname(collection, directory.meta.pathname);
if (item) {
deleteItemInCollection(item.uid, collection);
deleteItemInCollectionByPathname(directory.meta.pathname, collection);
}
}
},
@ -788,8 +767,6 @@ export const {
deleteItem,
renameItem,
cloneItem,
moveItem,
moveItemToRootOfCollection,
requestSent,
requestCancelled,
responseReceived,

View File

@ -1,4 +1,3 @@
import reckon from 'reckonjs';
import get from 'lodash/get';
import each from 'lodash/each';
import find from 'lodash/find';
@ -7,7 +6,14 @@ import isString from 'lodash/isString';
import map from 'lodash/map';
import filter from 'lodash/filter';
import sortBy from 'lodash/sortBy';
import isEqual from 'lodash/isEqual';
import cloneDeep from 'lodash/cloneDeep';
import { uuid } from 'utils/common';
import path from 'path';
// although we are not using rekonjs directly
// its populating the global string prototype with .reckon method
import reckon from 'reckonjs';
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
@ -128,6 +134,7 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
if (draggedItemParent) {
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
@ -135,15 +142,18 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
if (targetItem.type === 'folder') {
targetItem.items = targetItem.items || [];
targetItem.items.push(draggedItem);
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
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 {
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);
}
}
};
@ -151,13 +161,46 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
} else {
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
// If the dragged item is already at the root of the collection, do nothing
if(!draggedItemParent) {
return;
}
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
collection.items.push(draggedItem);
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 transformCollectionToSaveToIdb = (collection, options = {}) => {
@ -335,7 +378,6 @@ export const deleteItemInCollection = (itemUid, collection) => {
collection.items = filter(collection.items, (i) => i.uid !== itemUid);
let flattenedItems = flattenItems(collection.items);
each(flattenedItems, (i) => {
if (i.items && i.items.length) {
i.items = filter(i.items, (i) => i.uid !== itemUid);
@ -343,6 +385,17 @@ export const deleteItemInCollection = (itemUid, collection) => {
});
};
export const deleteItemInCollectionByPathname = (pathname, collection) => {
collection.items = filter(collection.items, (i) => i.pathname !== pathname);
let flattenedItems = flattenItems(collection.items);
each(flattenedItems, (i) => {
if (i.items && i.items.length) {
i.items = filter(i.items, (i) => i.pathname !== pathname);
}
});
};
export const isItemARequest = (item) => {
return item.hasOwnProperty('request') && ['http-request', 'graphql-request'].includes(item.type) && !item.items;
};
@ -449,6 +502,44 @@ export const interpolateEnvironmentVars = (item, variables) => {
return request;
};
export const deleteUidsInItem = (item) => {
delete item.uid;
const params = get(item, 'request.params', []);
const headers = get(item, 'request.headers', []);
const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(item, 'request.body.multipartForm', []);
params.forEach((param) => delete param.uid);
headers.forEach((header) => delete header.uid);
bodyFormUrlEncoded.forEach((param) => delete param.uid);
bodyMultipartForm.forEach((param) => delete param.uid);
return item;
};
export const areItemsTheSameExceptSeqUpdate = (_item1, _item2) => {
let item1 = cloneDeep(_item1);
let item2 = cloneDeep(_item2);
// remove seq from both items
delete item1.seq;
delete item2.seq;
// remove draft from both items
delete item1.draft;
delete item2.draft;
// get projection of both items
item1 = transformRequestToSaveToFilesystem(item1);
item2 = transformRequestToSaveToFilesystem(item2);
// delete uids from both items
deleteUidsInItem(item1);
deleteUidsInItem(item2);
return isEqual(item1, item2);
};
export const getDefaultRequestPaneTab = (item) => {
if(item.type === 'http-request') {
return 'params';

View File

@ -10,7 +10,8 @@ const {
envJsonToBru,
} = require('@usebruno/bruno-lang');
const { itemSchema } = require('@usebruno/schema');
const { generateUidBasedOnHash, uuid } = require('../utils/common');
const { uuid } = require('../utils/common');
const { getRequestUid } = require('../cache/requestUids');
const isJsonEnvironmentConfig = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
@ -28,7 +29,7 @@ const isBruEnvironmentConfig = (pathname, collectionPath) => {
};
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = generateUidBasedOnHash(pathname);
request.uid = getRequestUid(pathname);
const params = _.get(request, 'request.params', []);
const headers = _.get(request, 'request.headers', []);
@ -57,7 +58,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid) => {
const bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = generateUidBasedOnHash(pathname);
file.data.uid = getRequestUid(pathname);
_.each(_.get(file, 'data.variables', []), (variable) => variable.uid = uuid());
win.webContents.send('main:collection-tree-updated', 'addEnvironmentFile', file);
@ -80,7 +81,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid) => {
const bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = generateUidBasedOnHash(pathname);
file.data.uid = getRequestUid(pathname);
_.each(_.get(file, 'data.variables', []), (variable) => variable.uid = uuid());
// we are reusing the addEnvironmentFile event itself
@ -101,7 +102,7 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => {
name: path.basename(pathname),
},
data: {
uid: generateUidBasedOnHash(pathname),
uid: getRequestUid(pathname),
name: path.basename(pathname).substring(0, path.basename(pathname).length - 4),
}
};

View File

@ -0,0 +1,44 @@
/**
* we maintain a cache of request uids to ensure that we
* preserve the same uid for a request even when the request
* moves to a different location
*
* In the past, we used to generate unique ids based on the
* pathname of the request, but we faced problems when implementing
* functionality where the user can move the request to a different
* location. In that case, the uid would change, and the we would
* lose the request's draft state if the user has made some changes
*/
const requestUids = new Map();
const { uuid } = require('../utils/common');
const getRequestUid = (pathname) => {
let uid = requestUids.get(pathname);
if (!uid) {
uid = uuid();
requestUids.set(pathname, uid);
}
return uid;
};
const moveRequestUid = (oldPathname, newPathname) => {
const uid = requestUids.get(oldPathname);
if (uid) {
requestUids.delete(oldPathname);
requestUids.set(newPathname, uid);
}
};
const deleteRequestUid = (pathname) => {
requestUids.delete(pathname);
};
module.exports = {
getRequestUid,
moveRequestUid,
deleteRequestUid
};

View File

@ -13,11 +13,13 @@ const {
hasBruExtension,
isDirectory,
browseDirectory,
createDirectory
createDirectory,
searchForBruFiles
} = require('../utils/filesystem');
const { uuid, stringifyJson } = require('../utils/common');
const { stringifyJson } = require('../utils/common');
const { openCollectionDialog, openCollection } = require('../app/collections');
const { generateUidBasedOnHash } = require('../utils/common');
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
// browse directory
@ -179,6 +181,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// if its directory, rename and return
if(isDirectory(oldPath)) {
const bruFilesAtSource = await searchForBruFiles(oldPath);
for(let bruFile of bruFilesAtSource) {
const newBruFilePath = bruFile.replace(oldPath, newPath);
moveRequestUid(bruFile, newBruFilePath);
}
return fs.renameSync(oldPath, newPath);
}
@ -193,6 +201,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
jsonData.name = newName;
moveRequestUid(oldPath, newPath);
const content = jsonToBru(jsonData);
await writeFile(newPath, content);
await fs.unlinkSync(oldPath);
@ -218,9 +228,25 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:delete-item', async (event, pathname, type) => {
try {
if(type === 'folder') {
await fs.rmSync(pathname, { recursive: true, force: true});
if(!fs.existsSync(pathname)) {
return Promise.reject(new Error('The directory does not exist'));
}
// delete the request uid mappings
const bruFilesAtSource = await searchForBruFiles(pathname);
for(let bruFile of bruFilesAtSource) {
deleteRequestUid(bruFile);
}
fs.rmSync(pathname, { recursive: true, force: true});
} else if (['http-request', 'graphql-request'].includes(type)) {
await fs.unlinkSync(pathname);
if(!fs.existsSync(pathname)) {
return Promise.reject(new Error('The file does not exist'));
}
deleteRequestUid(pathname);
fs.unlinkSync(pathname);
} else {
return Promise.reject(error);
}
@ -308,6 +334,63 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => {
try {
for(let item of itemsToResequence) {
const bru = fs.readFileSync(item.pathname, 'utf8');
const jsonData = bruToJson(bru);
if(jsonData.seq !== item.seq) {
jsonData.seq = item.seq;
const content = jsonToBru(jsonData);
await writeFile(item.pathname, content);
}
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:move-file-item', async (event, itemPath, destinationPath) => {
try {
const itemContent = fs.readFileSync(itemPath, 'utf8');
const newItemPath = path.join(destinationPath, path.basename(itemPath));
moveRequestUid(itemPath, newItemPath);
fs.unlinkSync(itemPath);
fs.writeFileSync(newItemPath, itemContent);
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:move-folder-item', async (event, folderPath, destinationPath) => {
try {
const folderName = path.basename(folderPath);
const newFolderPath = path.join(destinationPath, folderName);
if(!fs.existsSync(folderPath)) {
throw new Error(`folder: ${folderPath} does not exist`);
}
if(fs.existsSync(newFolderPath)) {
throw new Error(`folder: ${newFolderPath} already exists`);
}
const bruFilesAtSource = await searchForBruFiles(folderPath);
for(let bruFile of bruFilesAtSource) {
const newBruFilePath = bruFile.replace(folderPath, newFolderPath);
moveRequestUid(bruFile, newBruFilePath);
}
fs.renameSync(folderPath, newFolderPath);
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:ready', async (event) => {
// reload last opened collections
const lastOpened = lastOpenedCollections.getAll();

View File

@ -95,6 +95,25 @@ const browseDirectory = async (win) => {
return isDirectory(resolvedPath) ? resolvedPath : false;
};
const searchForFiles = (dir, extension) => {
let results = [];
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
results = results.concat(searchForFiles(filePath, extension));
} else if (path.extname(file) === extension) {
results.push(filePath);
}
}
return results;
}
const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru');
};
module.exports = {
isValidPathname,
exists,
@ -106,5 +125,7 @@ module.exports = {
hasJsonExtension,
hasBruExtension,
createDirectory,
browseDirectory
browseDirectory,
searchForFiles,
searchForBruFiles
};

View File

@ -44,6 +44,7 @@ const bruToJson = (fileContents) => {
const json = {
type: parsed.type || '',
name: parsed.name || '',
seq: parsed.seq || 1,
request: {
method: parsed.method || '',
url: parsed.url || '',
@ -78,6 +79,7 @@ const jsonToBru = (json) => {
const {
type,
name,
seq,
request: {
method,
url,
@ -92,6 +94,7 @@ method ${method}
url ${url}
type ${type}
body-mode ${body ? body.mode : 'none'}
seq ${seq ? seq : 1}
`;
if(params && params.length) {

View File

@ -17,6 +17,7 @@ const inlineTag = sequenceOf([
str('name'),
str('method'),
str('url'),
str('seq'),
str('body-mode')
]),
whitespace,

View File

@ -58,6 +58,7 @@ const requestSchema = Yup.object({
const itemSchema = Yup.object({
uid: uidSchema,
type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder']).required('type is required'),
seq: Yup.number().min(1),
name: Yup.string()
.min(1, 'name must be atleast 1 characters')
.max(50, 'name must be 100 characters or less')
@ -69,7 +70,7 @@ const itemSchema = Yup.object({
items: Yup.lazy(() => Yup.array().of(itemSchema)),
filename: Yup.string().max(1024, 'filename cannot be more than 1024 characters').nullable(),
pathname: Yup.string().max(1024, 'pathname cannot be more than 1024 characters').nullable()
}).noUnknown(true).strict();
}).noUnknown(true);
const collectionSchema = Yup.object({
version: Yup.string().oneOf(['1']).required('version is required'),