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

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, Fragment } from 'react';
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 { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
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)) {
return (
<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
>
{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>
);
@@ -144,8 +144,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
<div
className="flex items-baseline tab-label pl-2"
className={`flex items-baseline tab-label pl-2 ${tab.preview ? "italic" : ""}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e);

View File

@@ -5,7 +5,7 @@ import classnames from 'classnames';
import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
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 { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
@@ -23,7 +23,9 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
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 tabs = useSelector((state) => state.tabs.tabs);
@@ -83,13 +85,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
'item-hovered': isOver
});
const scrollToTheActiveTab = () => {
const activeTab = document.querySelector('.request-tab.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
@@ -99,10 +94,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
};
const handleClick = (event) => {
if (event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
if (isItemARequest(item)) {
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
@@ -112,20 +110,21 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
})
);
return;
}
} else {
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
type: 'folder-settings',
})
);
dispatch(
@@ -134,9 +133,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
collectionUid: collection.uid
})
);
}
};
const handleFolderCollapse = () => {
const handleFolderCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
dispatch(
collectionFolderClicked({
itemUid: item.uid,
@@ -156,10 +158,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
const handleDoubleClick = (event) => {
setRenameItemModalOpen(true);
};
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
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
const sortRequestItems = (items = []) => {
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 { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch, useSelector } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
@@ -20,7 +20,8 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
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 [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -29,6 +30,7 @@ const Collection = ({ collection, searchText }) => {
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
@@ -60,9 +62,11 @@ const Collection = ({ collection, searchText }) => {
});
const handleClick = (event) => {
if (event.detail != 1) return;
// Check if the click came from the chevron icon
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
setTimeout(scrollToTheActiveTab, 50);
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
@@ -70,20 +74,30 @@ const Collection = ({ collection, searchText }) => {
brunoConfig: collection.brunoConfig
}));
}
dispatch(collapseCollection(collection.uid));
// Only open collection settings if not clicking the chevron
if(!isChevronClick) {
dispatch(
addTab({
uid: uuid(),
uid: 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 _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
@@ -158,6 +172,7 @@ const Collection = ({ collection, searchText }) => {
<div
className="flex flex-grow items-center overflow-hidden"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick}
>
<IconChevronRight
@@ -165,6 +180,7 @@ const Collection = ({ collection, searchText }) => {
strokeWidth={2}
className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleCollectionCollapse}
/>
<div className="ml-1" id="sidebar-collection-name">
{collection.name}

View File

@@ -6,12 +6,13 @@ import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
const isDevEnv = () => {
return import.meta.env.MODE === 'development';
};
let middleware = [tasksMiddleware.middleware];
let middleware = [tasksMiddleware.middleware, draftDetectMiddleware];
if (isDevEnv()) {
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 { findIndex } from 'lodash';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
@@ -10,40 +11,55 @@ const initialState = {
activeTabUid: null
};
const tabTypeAlreadyExists = (tabs, collectionUid, type) => {
return find(tabs, (tab) => tab.collectionUid === collectionUid && tab.type === type);
};
export const tabsSlice = createSlice({
name: 'tabs',
initialState,
reducers: {
addTab: (state, action) => {
const alreadyExists = find(state.tabs, (tab) => tab.uid === action.payload.uid);
if (alreadyExists) {
const { uid, collectionUid, type, requestPaneTab, preview } = action.payload;
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
if (existingTab) {
state.activeTabUid = existingTab.uid;
return;
}
const nonReplaceableTabTypes = [
"variables",
"collection-runner",
"security-settings",
];
if (
['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type)
) {
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
if (tab) {
state.activeTabUid = tab.uid;
return;
const lastTab = state.tabs[state.tabs.length - 1];
if (state.tabs.length > 0 && lastTab.preview) {
state.tabs[state.tabs.length - 1] = {
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
type: type || 'request',
preview: true,
...(uid ? { folderUid: uid } : {})
}
}
state.activeTabUid = uid;
return
}
state.tabs.push({
uid: action.payload.uid,
collectionUid: action.payload.collectionUid,
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: action.payload.requestPaneTab || 'params',
requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
type: action.payload.type || 'request',
...(action.payload.uid ? { folderUid: action.payload.uid } : {})
type: type || 'request',
...(uid ? { folderUid: uid } : {}),
preview: preview !== undefined
? preview
: !nonReplaceableTabTypes.includes(type)
});
state.activeTabUid = action.payload.uid;
state.activeTabUid = uid;
},
focusTab: (state, action) => {
state.activeTabUid = action.payload.uid;
@@ -124,6 +140,15 @@ export const tabsSlice = createSlice({
const collectionUid = action.payload.collectionUid;
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
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,
updateResponsePaneTab,
closeTabs,
closeAllCollectionTabs
closeAllCollectionTabs,
makeTabPermanent
} = 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) => {
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' });
}
};