mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-26 14:15:52 +00:00
feat: make tabs reorderable (#5413)
This commit is contained in:
@@ -4,7 +4,7 @@ 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';
|
||||
@@ -14,6 +14,8 @@ const RequestTabs = () => {
|
||||
const dispatch = useDispatch();
|
||||
const tabsRef = useRef();
|
||||
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
|
||||
const [draggedTabUid, setDraggedTabUid] = useState(null);
|
||||
const [dragOverTabUid, setDragOverTabUid] = useState(null);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
@@ -24,10 +26,42 @@ const RequestTabs = () => {
|
||||
const getTabClassname = (tab, index) => {
|
||||
return classnames('request-tab select-none', {
|
||||
active: tab.uid === activeTabUid,
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1,
|
||||
'dragged': tab.uid === draggedTabUid,
|
||||
'drag-over': tab.uid === dragOverTabUid && draggedTabUid !== null
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragStart = (e, tab) => {
|
||||
setDraggedTabUid(tab.uid);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', tab.uid);
|
||||
};
|
||||
|
||||
const handleDragOver = (e, tab) => {
|
||||
e.preventDefault();
|
||||
setDragOverTabUid(tab.uid);
|
||||
};
|
||||
|
||||
const handleDrop = (e, targetTab) => {
|
||||
e.preventDefault();
|
||||
setDragOverTabUid(null);
|
||||
const sourceUid = draggedTabUid;
|
||||
setDraggedTabUid(null);
|
||||
if (!sourceUid || sourceUid === targetTab.uid) {
|
||||
return;
|
||||
}
|
||||
dispatch(reorderTabs({
|
||||
sourceUid,
|
||||
targetUid: targetTab.uid
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedTabUid(null);
|
||||
setDragOverTabUid(null);
|
||||
};
|
||||
|
||||
const handleClick = (tab) => {
|
||||
dispatch(
|
||||
focusTab({
|
||||
@@ -111,6 +145,11 @@ const RequestTabs = () => {
|
||||
className={getTabClassname(tab, index)}
|
||||
role="tab"
|
||||
onClick={() => handleClick(tab)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, tab)}
|
||||
onDragOver={(e) => handleDragOver(e, tab)}
|
||||
onDrop={(e) => handleDrop(e, tab)}
|
||||
onDragEnd={() => handleDragEnd}
|
||||
>
|
||||
<RequestTab
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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' }
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
168
tests/collection/moving-tabs/move-tabs.spec.ts
Normal file
168
tests/collection/moving-tabs/move-tabs.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user