Feat: Move-Collection with Drag-and-Drop (#3755)

---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
Co-authored-by: ramki-bruno <ramki@usebruno.com>
This commit is contained in:
Sanjai Kumar
2025-02-10 20:46:42 +05:30
committed by GitHub
parent 4c1765e9f9
commit 89a4cd62bc
10 changed files with 110 additions and 46 deletions

View File

@@ -32,6 +32,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
const collectionItemRef = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -45,28 +46,31 @@ const CollectionItem = ({ item, collection, searchText }) => {
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const [{ isDragging }, drag] = useDrag({
type: `COLLECTION_ITEM_${collection.uid}`,
type: `collection-item-${collection.uid}`,
item: item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
}),
options: {
dropEffect: "move"
}
});
const [{ isOver }, drop] = useDrop({
accept: `COLLECTION_ITEM_${collection.uid}`,
accept: `collection-item-${collection.uid}`,
drop: (draggedItem) => {
if (draggedItem.uid !== item.uid) {
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
}
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
},
canDrop: (draggedItem) => {
return draggedItem.uid !== item.uid;
},
collect: (monitor) => ({
isOver: monitor.isOver()
})
isOver: monitor.isOver(),
}),
});
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
@@ -255,7 +259,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
<div className={itemRowClassName} ref={(node) => drag(drop(node))}>
<div className={itemRowClassName} ref={collectionItemRef}>
<div className="flex items-center h-full w-full">
{indents && indents.length
? indents.map((i) => {

View File

@@ -12,6 +12,17 @@ const Wrapper = styled.div`
transform: rotateZ(90deg);
}
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
}
}
.collection-actions {
.dropdown {
div[aria-expanded='true'] {

View File

@@ -2,11 +2,11 @@ import React, { useState, forwardRef, useRef, useEffect } from 'react';
import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
import { useDrop } from 'react-dnd';
import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -33,6 +33,7 @@ const Collection = ({ collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
@@ -124,26 +125,51 @@ const Collection = ({ collection, searchText }) => {
);
};
const isCollectionItem = (itemType) => {
return itemType.startsWith('collection-item');
};
const [{ isDragging }, drag] = useDrag({
type: "collection",
item: collection,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
options: {
dropEffect: "move"
}
});
const [{ isOver }, drop] = useDrop({
accept: `COLLECTION_ITEM_${collection.uid}`,
drop: (draggedItem) => {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid));
accept: ["collection", `collection-item-${collection.uid}`],
drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
} else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
}
},
canDrop: (draggedItem) => {
// todo need to make sure that draggedItem belongs to the collection
return true;
return draggedItem.uid !== collection.uid;
},
collect: (monitor) => ({
isOver: monitor.isOver()
})
isOver: monitor.isOver(),
}),
});
drag(drop(collectionRef));
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
}
}
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
'item-hovered': isOver
});
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
@@ -173,7 +199,9 @@ const Collection = ({ collection, searchText }) => {
{showCloneCollectionModalOpen && (
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
)}
<div className="flex py-1 collection-name items-center" ref={drop}>
<div className={collectionRowClassName}
ref={collectionRef}
>
<div
className="flex flex-grow items-center overflow-hidden"
onClick={handleClick}

View File

@@ -8,12 +8,10 @@ import {
IconSortDescendingLetters,
IconX
} from '@tabler/icons';
import Collection from '../Collections/Collection';
import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// todo: move this to a separate folder
@@ -119,9 +117,7 @@ const Collections = () => {
{collections && collections.length
? collections.map((c) => {
return (
<DndProvider backend={HTML5Backend} key={c.uid}>
<Collection searchText={searchText} collection={c} key={c.uid} />
</DndProvider>
<Collection searchText={searchText} collection={c} key={c.uid} />
);
})
: null}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './pages/index';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
const rootElement = document.getElementById('root');
@@ -8,7 +10,9 @@ if (rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
</React.StrictMode>
);
}

View File

@@ -32,6 +32,7 @@ import {
selectEnvironment as _selectEnvironment,
sortCollections as _sortCollections,
updateCollectionMountStatus,
moveCollection,
requestCancelled,
resetRunResults,
responseReceived,
@@ -1151,6 +1152,22 @@ export const importCollection = (collection, collectionLocation) => (dispatch, g
});
};
export const moveCollectionAndPersist = ({ draggedItem, targetItem }) => (dispatch, getState) => {
dispatch(moveCollection({ draggedItem, targetItem }));
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const collectionPaths = state.collections.collections.map((collection) => collection.pathname);
ipcRenderer
.invoke('renderer:update-collection-paths', collectionPaths)
.then(resolve)
.catch(reject);
});
};
export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;

View File

@@ -1,5 +1,5 @@
import { uuid } from 'utils/common';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set } from 'lodash';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
import {
addDepth,
@@ -100,6 +100,12 @@ export const collectionsSlice = createSlice({
break;
}
},
moveCollection: (state, action) => {
const { draggedItem, targetItem } = action.payload;
state.collections = state.collections.filter((i) => i.uid !== draggedItem.uid); // Remove dragged item
const targetItemIndex = state.collections.findIndex((i) => i.uid === targetItem.uid); // Find target item
state.collections.splice(targetItemIndex, 0, draggedItem); // Insert dragged-item above target-item
},
updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -2082,7 +2088,8 @@ export const {
runFolderEvent,
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs
updateFolderDocs,
moveCollection
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -1,13 +1,4 @@
import get from 'lodash/get';
import each from 'lodash/each';
import find from 'lodash/find';
import findIndex from 'lodash/findIndex';
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 {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import path from 'path';
import slash from 'utils/common/slash';

View File

@@ -516,6 +516,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:update-collection-paths', async (_, collectionPaths) => {
lastOpenedCollections.update(collectionPaths);
})
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => {
try {
let collectionName = sanitizeDirectoryName(collection.name);

View File

@@ -16,18 +16,20 @@ class LastOpenedCollections {
}
add(collectionPath) {
const collections = this.store.get('lastOpenedCollections') || [];
const collections = this.getAll();
if (isDirectory(collectionPath)) {
if (!collections.includes(collectionPath)) {
collections.push(collectionPath);
this.store.set('lastOpenedCollections', collections);
}
if (isDirectory(collectionPath) && !collections.includes(collectionPath)) {
collections.push(collectionPath);
this.store.set('lastOpenedCollections', collections);
}
}
update(collectionPaths) {
this.store.set('lastOpenedCollections', collectionPaths);
}
remove(collectionPath) {
let collections = this.store.get('lastOpenedCollections') || [];
let collections = this.getAll();
if (collections.includes(collectionPath)) {
collections = _.filter(collections, (c) => c !== collectionPath);
@@ -36,7 +38,7 @@ class LastOpenedCollections {
}
removeAll() {
return this.store.set('lastOpenedCollections', []);
this.store.set('lastOpenedCollections', []);
}
}