Merge pull request #5666 from usebruno/feat/tab-reordering-internal

feat: extended additions for tab reordering (#5413)
This commit is contained in:
Anoop M D
2025-10-02 08:44:28 +05:30
committed by GitHub
6 changed files with 292 additions and 8 deletions

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { useDrag, useDrop } from 'react-dnd';
const DraggableTab = ({ id, onMoveTab, index, children, className, onClick }) => {
const ref = React.useRef(null);
const [{ handlerId, isOver }, drop] = useDrop({
accept: 'tab',
hover(item, monitor) {
onMoveTab(item.id, id);
},
collect: (monitor) => ({
handlerId: monitor.getHandlerId(),
isOver: monitor.isOver()
})
});
const [{ isDragging }, drag] = useDrag({
type: 'tab',
item: () => {
return { id, index };
},
collect: (monitor) => ({
isDragging: monitor.isDragging()
}),
options: {
dropEffect: 'move'
}
});
drag(drop(ref));
return (
<li
className={className}
ref={ref}
role="tab"
style={{ opacity: isDragging || isOver ? 0 : 1 }}
onClick={onClick}
data-handler-id={handlerId}
>
{children}
</li>
);
};
export default DraggableTab;

View File

@@ -4,11 +4,12 @@ import filter from 'lodash/filter';
import classnames from 'classnames';
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
const RequestTabs = () => {
const dispatch = useDispatch();
@@ -106,10 +107,17 @@ const RequestTabs = () => {
{collectionRequestTabs && collectionRequestTabs.length
? collectionRequestTabs.map((tab, index) => {
return (
<li
<DraggableTab
key={tab.uid}
id={tab.uid}
index={index}
onMoveTab={(source, target) => {
dispatch(reorderTabs({
sourceUid: source,
targetUid: target
}));
}}
className={getTabClassname(tab, index)}
role="tab"
onClick={() => handleClick(tab)}
>
<RequestTab
@@ -120,7 +128,7 @@ const RequestTabs = () => {
collection={activeCollection}
folderUid={tab.folderUid}
/>
</li>
</DraggableTab>
);
})
: null}

View File

@@ -14,7 +14,7 @@ import {
saveFolderRoot
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
@@ -252,6 +252,30 @@ export const HotkeysProvider = (props) => {
};
}, [dispatch]);
// Move tab left
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => {
dispatch(reorderTabs({ direction: -1 }));
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]);
};
}, [dispatch]);
// Move tab right
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => {
dispatch(reorderTabs({ direction: 1 }));
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]);
};
}, [dispatch]);
const currentCollection = getCurrentCollection();
return (

View File

@@ -21,6 +21,16 @@ const KeyMapping = {
windows: 'ctrl+pagedown',
name: 'Switch to Next Tab'
},
moveTabLeft: {
mac: 'command+shift+pageup',
windows: 'ctrl+shift+pageup',
name: 'Move Tab Left'
},
moveTabRight: {
mac: 'command+shift+pagedown',
windows: 'ctrl+shift+pagedown',
name: 'Move Tab Right'
},
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }
};

View File

@@ -1,5 +1,4 @@
import { createSlice } from '@reduxjs/toolkit';
import { findIndex } from 'lodash';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
@@ -178,6 +177,33 @@ export const tabsSlice = createSlice({
} else {
console.error('Tab not found!');
}
},
reorderTabs: (state, action) => {
const { direction, sourceUid, targetUid } = action.payload;
const tabs = state.tabs;
let sourceIdx, targetIdx;
if (direction) {
sourceIdx = tabs.findIndex((t) => t.uid === state.activeTabUid);
if (sourceIdx < 0) {
return;
}
targetIdx = sourceIdx + (direction === -1 ? -1 : 1);
} else {
sourceIdx = tabs.findIndex((t) => t.uid === sourceUid);
targetIdx = tabs.findIndex((t) => t.uid === targetUid);
}
const sourceBoundary = sourceIdx < 0;
const targetBoundary = targetIdx < 0 || targetIdx >= tabs.length;
if (sourceBoundary || sourceIdx === targetIdx || targetBoundary) {
return;
}
const [moved] = tabs.splice(sourceIdx, 1);
tabs.splice(targetIdx, 0, moved);
state.tabs = tabs;
}
}
});
@@ -192,7 +218,8 @@ export const {
updateResponsePaneScrollPosition,
closeTabs,
closeAllCollectionTabs,
makeTabPermanent
makeTabPermanent,
reorderTabs
} = tabsSlice.actions;
export default tabsSlice.reducer;
export default tabsSlice.reducer;

View File

@@ -0,0 +1,168 @@
import { test, expect } from '../../../playwright';
test.describe('Move tabs', () => {
test('Verify tab move by drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
// Create a collection
await page.locator('.dropdown-icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
await page.getByLabel('Name').fill('source-collection');
await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
// Wait for collection to appear and click on it
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
// Create a folder in the collection
const sourceCollection = page.locator('.collection-name').filter({ hasText: 'source-collection' });
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
// Fill folder name in the modal
await expect(page.locator('#collection-name')).toBeVisible();
await page.locator('#collection-name').fill('test-folder');
await page.getByRole('button', { name: 'Create' }).click();
// Wait for the folder to be created and appear in the sidebar
await page.waitForTimeout(2000);
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toBeVisible();
// Open the folder tab
await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).dblclick();
await page.waitForTimeout(500);
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' })).toBeVisible();
// Add a request to the collection
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('test-request');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('https://httpbin.org/get');
await page.getByRole('button', { name: 'Create' }).click();
// Wait for the request to be created
await page.waitForTimeout(1000);
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toBeVisible();
// Open the request tab
await page.locator('.collection-item-name').filter({ hasText: 'test-request' }).dblclick();
await page.waitForTimeout(500);
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' })).toBeVisible();
// Verify order of tabs before move
const tabs = page.locator('.request-tab .tab-label');
await expect(tabs.nth(0)).toHaveText('test-folder');
await expect(tabs.nth(1)).toHaveText('GETtest-request');
// Drag and drop the request tab before the folder tab
let source = page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' });
let target = page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' });
let sourceBox = await source.boundingBox();
let targetBox = await target.boundingBox();
if (sourceBox && targetBox) {
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await page.mouse.down();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 5 });
await page.mouse.up();
}
// Verify order of tabs after drag and drop
await expect(tabs.nth(0)).toHaveText('GETtest-request');
await expect(tabs.nth(1)).toHaveText('test-folder');
// Drag and drop the request tab back to its original position
source = page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' });
target = page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' });
sourceBox = await source.boundingBox();
targetBox = await target.boundingBox();
if (sourceBox && targetBox) {
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await page.mouse.down();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height + 10, { steps: 5 });
await page.mouse.up();
}
});
test('Verify tab move by keyboard shortcut', async ({ pageWithUserData: page, createTmpDir }) => {
// Create a collection
await page.locator('.dropdown-icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
await page.getByLabel('Name').fill('source-collection');
await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
// Wait for collection to appear and click on it
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
// Create a folder in the collection
const sourceCollection = page.locator('.collection-name').filter({ hasText: 'source-collection' });
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
// Fill folder name in the modal
await expect(page.locator('#collection-name')).toBeVisible();
await page.locator('#collection-name').fill('test-folder');
await page.getByRole('button', { name: 'Create' }).click();
// Wait for the folder to be created and appear in the sidebar
await page.waitForTimeout(2000);
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toBeVisible();
// Open the folder tab
await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).dblclick();
await page.waitForTimeout(500);
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' })).toBeVisible();
// Add a request to the collection
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('test-request');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('https://httpbin.org/get');
await page.getByRole('button', { name: 'Create' }).click();
// Wait for the request to be created
await page.waitForTimeout(1000);
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toBeVisible();
// Open the request tab
await page.locator('.collection-item-name').filter({ hasText: 'test-request' }).dblclick();
await page.waitForTimeout(500);
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' })).toBeVisible();
// Verify order of tabs before move
const tabs = page.locator('.request-tab .tab-label');
await expect(tabs.nth(0)).toHaveText('test-folder');
await expect(tabs.nth(1)).toHaveText('GETtest-request');
// Move the request tab before the folder tab using keyboard shortcut
const source = page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' });
await source.click();
await page.keyboard.press('ControlOrMeta+Shift+PageUp');
await page.waitForTimeout(500);
// Verify order of tabs after move
await expect(tabs.nth(0)).toHaveText('GETtest-request');
await expect(tabs.nth(1)).toHaveText('test-folder');
// Move the request tab back to its original position using keyboard shortcut
await source.click();
await page.keyboard.press('ControlOrMeta+Shift+PageDown');
await page.waitForTimeout(500);
// Verify order of tabs after move
await expect(tabs.nth(0)).toHaveText('test-folder');
await expect(tabs.nth(1)).toHaveText('GETtest-request');
});
});