Feature: Improve tab UX (#3831)

---------
Co-authored-by: ramki-bruno <ramki@usebruno.com>
This commit is contained in:
naman-bruno
2025-02-06 19:34:10 +05:30
committed by GitHub
parent 038f2d1f0b
commit 722d9788ca
9 changed files with 189 additions and 60 deletions

View File

@@ -2,15 +2,15 @@ import React from 'react';
import CloseTabIcon from './CloseTabIcon'; import CloseTabIcon from './CloseTabIcon';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons'; import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName }) => { const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
const getTabInfo = (type, tabName) => { const getTabInfo = (type, tabName) => {
switch (type) { switch (type) {
case 'collection-settings': { case 'collection-settings': {
return ( return (
<> <div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" /> <IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span> <span className="ml-1 leading-6">Collection</span>
</> </div>
); );
} }
case 'collection-overview': { case 'collection-overview': {
@@ -31,7 +31,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
} }
case 'folder-settings': { case 'folder-settings': {
return ( return (
<div className="flex items-center flex-nowrap overflow-hidden"> <div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" /> <IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span> <span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
</div> </div>

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, Fragment } from 'react'; import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import { closeTabs } from 'providers/ReduxStore/slices/tabs'; import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme'; import { useTheme } from 'providers/Theme';
@@ -73,13 +73,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) { if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return ( return (
<StyledWrapper <StyledWrapper
className="flex items-center justify-between tab-container px-1" className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here onMouseUp={handleMouseUp} // Add middle-click behavior here
> >
{tab.type === 'folder-settings' ? ( {tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} tabName={folder?.name} /> <SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : ( ) : (
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} /> <SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)} )}
</StyledWrapper> </StyledWrapper>
); );
@@ -144,8 +144,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/> />
)} )}
<div <div
className="flex items-baseline tab-label pl-2" className={`flex items-baseline tab-label pl-2 ${tab.preview ? "italic" : ""}`}
onContextMenu={handleRightClick} onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => { onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e); if (!item.draft) return handleMouseUp(e);

View File

@@ -5,7 +5,7 @@ import classnames from 'classnames';
import { useDrag, useDrop } from 'react-dnd'; 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 } 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 { moveItem, showInFolder, sendRequest } 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';
@@ -23,7 +23,9 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index'; import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemIcon from './CollectionItemIcon/index'; import { findItemInCollection } from 'utils/collections';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
const CollectionItem = ({ item, collection, searchText }) => { const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs); const tabs = useSelector((state) => state.tabs.tabs);
@@ -83,13 +85,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
'item-hovered': isOver 'item-hovered': isOver
}); });
const scrollToTheActiveTab = () => {
const activeTab = document.querySelector('.request-tab.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleRun = async () => { const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) => dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, { toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
@@ -99,10 +94,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
}; };
const handleClick = (event) => { const handleClick = (event) => {
if (event.detail != 1) return;
//scroll to the active tab //scroll to the active tab
setTimeout(scrollToTheActiveTab, 50); setTimeout(scrollToTheActiveTab, 50);
if (isItemARequest(item)) { const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage()); dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) { if (itemIsOpenedInTabs(item, tabs)) {
dispatch( dispatch(
@@ -112,20 +110,21 @@ const CollectionItem = ({ item, collection, searchText }) => {
); );
return; return;
} }
dispatch( dispatch(
addTab({ addTab({
uid: item.uid, uid: item.uid,
collectionUid: collection.uid, collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item) requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
}) })
); );
return; } else {
}
dispatch( dispatch(
addTab({ addTab({
uid: item.uid, uid: item.uid,
collectionUid: collection.uid, collectionUid: collection.uid,
type: 'folder-settings' type: 'folder-settings',
}) })
); );
dispatch( dispatch(
@@ -134,9 +133,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
collectionUid: collection.uid collectionUid: collection.uid
}) })
); );
}
}; };
const handleFolderCollapse = () => { const handleFolderCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
dispatch( dispatch(
collectionFolderClicked({ collectionFolderClicked({
itemUid: item.uid, itemUid: item.uid,
@@ -156,10 +158,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
} }
}; };
const handleDoubleClick = (event) => {
setRenameItemModalOpen(true);
};
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 isFolder = isItemAFolder(item);
@@ -180,6 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
} }
} }
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid }))
};
// we need to sort request items by seq property // we need to sort request items by seq property
const sortRequestItems = (items = []) => { const sortRequestItems = (items = []) => {
return items.sort((a, b) => a.seq - b.seq); return items.sort((a, b) => a.seq - b.seq);

View File

@@ -7,8 +7,8 @@ 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 } from 'providers/ReduxStore/slices/collections/actions'; import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { addTab } 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';
import NewFolder from 'components/Sidebar/NewFolder'; import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem'; import CollectionItem from './CollectionItem';
@@ -20,7 +20,8 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection'; import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection'; import CloneCollection from './CloneCollection';
import { areItemsLoading } from 'utils/collections'; import { areItemsLoading, findItemInCollection } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
const Collection = ({ collection, searchText }) => { const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false); const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -29,6 +30,7 @@ const Collection = ({ collection, searchText }) => {
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false); const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showExportCollectionModal, setShowExportCollectionModal] = useState(false); const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const dispatch = useDispatch(); const dispatch = useDispatch();
const isLoading = areItemsLoading(collection); const isLoading = areItemsLoading(collection);
@@ -60,8 +62,10 @@ const Collection = ({ collection, searchText }) => {
}); });
const handleClick = (event) => { const handleClick = (event) => {
if (event.detail != 1) return;
// Check if the click came from the chevron icon // Check if the click came from the chevron icon
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon'); const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
setTimeout(scrollToTheActiveTab, 50);
if (collection.mountStatus === 'unmounted') { if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({ dispatch(mountCollection({
@@ -70,20 +74,30 @@ const Collection = ({ collection, searchText }) => {
brunoConfig: collection.brunoConfig brunoConfig: collection.brunoConfig
})); }));
} }
dispatch(collapseCollection(collection.uid)); dispatch(collapseCollection(collection.uid));
// Only open collection settings if not clicking the chevron
if(!isChevronClick) { if(!isChevronClick) {
dispatch( dispatch(
addTab({ addTab({
uid: uuid(), uid: collection.uid,
collectionUid: collection.uid, collectionUid: collection.uid,
type: 'collection-settings' type: 'collection-settings',
}) })
); );
} }
}; };
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: collection.uid }))
};
const handleCollectionCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
dispatch(collapseCollection(collection.uid));
}
const handleRightClick = (event) => { const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current; const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) { if (_menuDropdown) {
@@ -158,6 +172,7 @@ const Collection = ({ collection, searchText }) => {
<div <div
className="flex flex-grow items-center overflow-hidden" className="flex flex-grow items-center overflow-hidden"
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick} onContextMenu={handleRightClick}
> >
<IconChevronRight <IconChevronRight
@@ -165,6 +180,7 @@ const Collection = ({ collection, searchText }) => {
strokeWidth={2} strokeWidth={2}
className={`chevron-icon ${iconClassName}`} className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }} style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleCollectionCollapse}
/> />
<div className="ml-1" id="sidebar-collection-name"> <div className="ml-1" id="sidebar-collection-name">
{collection.name} {collection.name}

View File

@@ -6,12 +6,13 @@ import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs'; import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications'; import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments'; import globalEnvironmentsReducer from './slices/global-environments';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
const isDevEnv = () => { const isDevEnv = () => {
return import.meta.env.MODE === 'development'; return import.meta.env.MODE === 'development';
}; };
let middleware = [tasksMiddleware.middleware]; let middleware = [tasksMiddleware.middleware, draftDetectMiddleware];
if (isDevEnv()) { if (isDevEnv()) {
middleware = [...middleware, debugMiddleware.middleware]; middleware = [...middleware, debugMiddleware.middleware];
} }

View File

@@ -0,0 +1,55 @@
import { handleMakeTabParmanent } from "./utils";
const actionsToIntercept = [
'collections/requestUrlChanged',
'collections/updateAuth',
'collections/addQueryParam',
'collections/moveQueryParam',
'collections/updateQueryParam',
'collections/deleteQueryParam',
'collections/updatePathParam',
'collections/addRequestHeader',
'collections/updateRequestHeader',
'collections/deleteRequestHeader',
'collections/moveRequestHeader',
'collections/addFormUrlEncodedParam',
'collections/updateFormUrlEncodedParam',
'collections/deleteFormUrlEncodedParam',
'collections/moveFormUrlEncodedParam',
'collections/addMultipartFormParam',
'collections/updateMultipartFormParam',
'collections/deleteMultipartFormParam',
'collections/moveMultipartFormParam',
'collections/updateRequestAuthMode',
'collections/updateRequestBodyMode',
'collections/updateRequestBody',
'collections/updateRequestGraphqlQuery',
'collections/updateRequestGraphqlVariables',
'collections/updateRequestScript',
'collections/updateResponseScript',
'collections/updateRequestTests',
'collections/updateRequestMethod',
'collections/addAssertion',
'collections/updateAssertion',
'collections/deleteAssertion',
'collections/moveAssertion',
'collections/addVar',
'collections/updateVar',
'collections/deleteVar',
'collections/moveVar',
'collections/addFolderHeader',
'collections/updateFolderHeader',
'collections/deleteFolderHeader',
'collections/addFolderVar',
'collections/updateFolderVar',
'collections/deleteFolderVar',
'collections/updateRequestDocs'
];
export const draftDetectMiddleware = ({ dispatch, getState }) => (next) => (action) => {
if (actionsToIntercept.includes(action.type)) {
const state = getState();
handleMakeTabParmanent(state, action, dispatch);
}
return next(action);
};

View File

@@ -0,0 +1,21 @@
import { makeTabPermanent } from "providers/ReduxStore/slices/tabs";
import { findCollectionByUid, findItemInCollection } from "utils/collections/index";
import find from 'lodash/find';
function handleMakeTabParmanent(state, action, dispatch) {
const tabs = state.tabs.tabs;
const activeTabUid = state.tabs.activeTabUid;
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const itemUid = action.payload.itemUid || action.payload.folderUid
const collection = findCollectionByUid(state.collections.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item && focusedTab.preview == true) {
dispatch(makeTabPermanent({ uid: itemUid }));
}
}
}
export {
handleMakeTabParmanent
}

View File

@@ -1,4 +1,5 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { findIndex } from 'lodash';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import find from 'lodash/find'; import find from 'lodash/find';
import last from 'lodash/last'; import last from 'lodash/last';
@@ -10,40 +11,55 @@ const initialState = {
activeTabUid: null activeTabUid: null
}; };
const tabTypeAlreadyExists = (tabs, collectionUid, type) => {
return find(tabs, (tab) => tab.collectionUid === collectionUid && tab.type === type);
};
export const tabsSlice = createSlice({ export const tabsSlice = createSlice({
name: 'tabs', name: 'tabs',
initialState, initialState,
reducers: { reducers: {
addTab: (state, action) => { addTab: (state, action) => {
const alreadyExists = find(state.tabs, (tab) => tab.uid === action.payload.uid); const { uid, collectionUid, type, requestPaneTab, preview } = action.payload;
if (alreadyExists) {
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
if (existingTab) {
state.activeTabUid = existingTab.uid;
return; return;
} }
const nonReplaceableTabTypes = [
"variables",
"collection-runner",
"security-settings",
];
if ( const lastTab = state.tabs[state.tabs.length - 1];
['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type) if (state.tabs.length > 0 && lastTab.preview) {
) { state.tabs[state.tabs.length - 1] = {
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type); uid,
if (tab) { collectionUid,
state.activeTabUid = tab.uid; requestPaneWidth: null,
return; requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
type: type || 'request',
preview: true,
...(uid ? { folderUid: uid } : {})
} }
state.activeTabUid = uid;
return
} }
state.tabs.push({ state.tabs.push({
uid: action.payload.uid, uid,
collectionUid: action.payload.collectionUid, collectionUid,
requestPaneWidth: null, requestPaneWidth: null,
requestPaneTab: action.payload.requestPaneTab || 'params', requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response', responsePaneTab: 'response',
type: action.payload.type || 'request', type: type || 'request',
...(action.payload.uid ? { folderUid: action.payload.uid } : {}) ...(uid ? { folderUid: uid } : {}),
preview: preview !== undefined
? preview
: !nonReplaceableTabTypes.includes(type)
}); });
state.activeTabUid = action.payload.uid; state.activeTabUid = uid;
}, },
focusTab: (state, action) => { focusTab: (state, action) => {
state.activeTabUid = action.payload.uid; state.activeTabUid = action.payload.uid;
@@ -124,6 +140,15 @@ export const tabsSlice = createSlice({
const collectionUid = action.payload.collectionUid; const collectionUid = action.payload.collectionUid;
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid); state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
state.activeTabUid = null; state.activeTabUid = null;
},
makeTabPermanent: (state, action) => {
const { uid } = action.payload;
const tab = find(state.tabs, (t) => t.uid === uid);
if (tab) {
tab.preview = false;
} else{
console.error("Tab not found!")
}
} }
} }
}); });
@@ -136,7 +161,8 @@ export const {
updateRequestPaneTab, updateRequestPaneTab,
updateResponsePaneTab, updateResponsePaneTab,
closeTabs, closeTabs,
closeAllCollectionTabs closeAllCollectionTabs,
makeTabPermanent
} = tabsSlice.actions; } = tabsSlice.actions;
export default tabsSlice.reducer; export default tabsSlice.reducer;

View File

@@ -11,3 +11,10 @@ export const isItemAFolder = (item) => {
export const itemIsOpenedInTabs = (item, tabs) => { export const itemIsOpenedInTabs = (item, tabs) => {
return find(tabs, (t) => t.uid === item.uid); return find(tabs, (t) => t.uid === item.uid);
}; };
export const scrollToTheActiveTab = () => {
const activeTab = document.querySelector('.request-tab.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};