Feat/response tabs rewamp (#6388)

* refactor: used common component for layout switching button

* refactor: replace RequestPaneTabs with ResponsiveTabs component across RequestPane and HttpRequestPane

* refactor: simplify ResponsePaneActions component and improve layout handling

* refactor: enhance ResponsePane component with improved tab handling and layout adjustments

* refactor: update layout toggle functionality and button labels in ResponsePane components

* refactor: ensure consistent action button selection in response actions
This commit is contained in:
Abhishek S Lal
2025-12-12 17:33:07 +05:30
committed by GitHub
parent 6652cca642
commit 2327b21c85
15 changed files with 517 additions and 461 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconPin, IconPinned, IconPlus } from '@tabler/icons';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
@@ -18,6 +18,7 @@ import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import StyledWrapper from './StyledWrapper';
import { toTitleCase } from 'utils/common/index';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
const AppTitleBar = () => {
const dispatch = useDispatch();
@@ -94,8 +95,6 @@ const AppTitleBar = () => {
dispatch(savePreferences(newPreferences));
}, [dispatch, preferences]);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const handleToggleSidebar = () => {
dispatch(toggleSidebarCollapse());
};
@@ -108,18 +107,6 @@ const AppTitleBar = () => {
}
};
const handleToggleVerticalLayout = () => {
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences?.layout || {},
responsePaneOrientation: newOrientation
}
};
dispatch(savePreferences(updatedPreferences));
};
// Build workspace menu items
const workspaceMenuItems = useMemo(() => {
const items = sortedWorkspaces.map((workspace) => {
@@ -230,19 +217,7 @@ const AppTitleBar = () => {
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
</ActionIcon>
{/* Toggle vertical layout */}
<ActionIcon
onClick={handleToggleVerticalLayout}
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
size="lg"
data-testid="toggle-vertical-layout-button"
>
{orientation === 'horizontal' ? (
<IconLayoutColumns size={16} stroke={1.5} />
) : (
<IconLayoutRows size={16} stroke={1.5} />
)}
</ActionIcon>
<ResponseLayoutToggle />
</div>
</div>
</StyledWrapper>

View File

@@ -19,7 +19,7 @@ import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Settings from 'components/RequestPane/Settings';
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
import ResponsiveTabs from 'ui/ResponsiveTabs';
const MULTIPLE_CONTENT_TABS = new Set(['script', 'vars', 'auth', 'docs']);
@@ -146,7 +146,7 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
return (
<div className="flex flex-col h-full relative">
<RequestPaneTabs
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}

View File

@@ -15,7 +15,7 @@ import Tests from 'components/RequestPane/Tests';
import Settings from 'components/RequestPane/Settings';
import Documentation from 'components/Documentation/index';
import StatusDot from 'components/StatusDot';
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const MULTIPLE_CONTENT_TABS = new Set(['params', 'script', 'vars', 'auth', 'docs']);
@@ -137,7 +137,7 @@ const HttpRequestPane = ({ item, collection }) => {
return (
<div className="flex flex-col h-full relative">
<RequestPaneTabs
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
min-width: 125px;
white-space: nowrap;
.body-mode-selector {
background: transparent;

View File

@@ -1,178 +0,0 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import { IconChevronDown } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const DROPDOWN_WIDTH = 60;
const CALCULATION_DELAY_DEFAULT = 20;
const CALCULATION_DELAY_EXTENDED = 150;
const RequestPaneTabs = ({
tabs,
activeTab,
onTabSelect,
rightContent,
rightContentRef,
delayedTabs = []
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
const tabsContainerRef = useRef(null);
const tabRefsMap = useRef({});
const dropdownTippyRef = useRef(null);
const handleTabSelect = useCallback(
(tabKey) => {
onTabSelect(tabKey);
dropdownTippyRef.current?.hide();
},
[onTabSelect]
);
const calculateTabVisibility = useCallback(() => {
const container = tabsContainerRef.current;
if (!container || !tabs.length) return;
const containerWidth = container.offsetWidth;
const rightContentWidth = rightContentRef?.current
? rightContentRef.current.offsetWidth + 20
: 0;
const availableWidth = containerWidth - rightContentWidth - DROPDOWN_WIDTH;
const visible = [];
const overflow = [];
let currentWidth = 0;
for (const tab of tabs) {
const tabElement = tabRefsMap.current[tab.key];
const tabWidth = tabElement ? tabElement.offsetWidth + 20 : 100;
if (currentWidth + tabWidth <= availableWidth && !overflow.length) {
visible.push(tab);
currentWidth += tabWidth;
} else {
overflow.push(tab);
}
}
if (!visible.some((t) => t.key === activeTab) && overflow.length) {
const activeTabIndex = overflow.findIndex((t) => t.key === activeTab);
if (activeTabIndex !== -1) {
const [activeTabItem] = overflow.splice(activeTabIndex, 1);
const lastVisible = visible.pop();
if (lastVisible) overflow.unshift(lastVisible);
visible.push(activeTabItem);
}
}
setVisibleTabs(visible);
setOverflowTabs(overflow);
}, [tabs, activeTab, rightContentRef]);
const renderTab = useCallback(
(tab, isInDropdown = false) => {
const isActive = tab.key === activeTab;
if (isInDropdown) {
return (
<div
key={tab.key}
className={classnames('dropdown-item', { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
>
<span className="flex items-center gap-1">
{tab.label}
{tab.indicator}
</span>
</div>
);
}
return (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
);
},
[activeTab, handleTabSelect]
);
useEffect(() => {
const delay = delayedTabs.includes(activeTab) ? CALCULATION_DELAY_EXTENDED : CALCULATION_DELAY_DEFAULT;
const timeoutId = setTimeout(() => requestAnimationFrame(calculateTabVisibility), delay);
return () => clearTimeout(timeoutId);
}, [calculateTabVisibility, activeTab, delayedTabs]);
useEffect(() => {
let frameId = null;
const observer = new ResizeObserver(() => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(calculateTabVisibility);
});
if (tabsContainerRef.current) observer.observe(tabsContainerRef.current);
if (rightContentRef?.current) observer.observe(rightContentRef.current);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [calculateTabVisibility, rightContentRef]);
const hiddenStyle = useMemo(
() => ({ visibility: 'hidden', position: 'absolute', display: 'flex', pointerEvents: 'none' }),
[]
);
return (
<StyledWrapper ref={tabsContainerRef} className="flex items-center tabs" role="tablist">
<div style={hiddenStyle}>
{tabs.map((tab) => (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: tab.key === activeTab })}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
))}
</div>
{visibleTabs.map((tab) => renderTab(tab))}
{overflowTabs.length > 0 && (
<Dropdown
icon={(
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
<span>More</span>
<IconChevronDown size={14} strokeWidth={2} />
</div>
)}
placement="bottom-start"
onCreate={(instance) => (dropdownTippyRef.current = instance)}
>
<div style={{ minWidth: '150px' }}>{overflowTabs.map((tab) => renderTab(tab, true))}</div>
</Dropdown>
)}
{rightContent && (
<div className="flex flex-grow justify-end items-center">
{rightContent}
</div>
)}
</StyledWrapper>
);
};
export default RequestPaneTabs;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import find from 'lodash/find';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
@@ -36,7 +36,7 @@ import ResponseExample from 'components/ResponseExample';
import WorkspaceHome from 'components/WorkspaceHome';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
const MIN_RIGHT_PANE_WIDTH = 480;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
@@ -53,9 +53,15 @@ const RequestTabPanel = () => {
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
// Use ref to avoid stale closure in event handlers
const isVerticalLayoutRef = useRef(isVerticalLayout);
useEffect(() => {
isVerticalLayoutRef.current = isVerticalLayout;
}, [isVerticalLayout]);
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
let collections = produce(_collections, (draft) => {
let collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
const collections = produce(_collections, (draft) => {
const collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
if (collection) {
// add selected global env variables to the collection object
@@ -69,62 +75,65 @@ const RequestTabPanel = () => {
}
});
let collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const draggingRef = useRef(false);
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
const docExplorerRef = useRef(null);
const mainSectionRef = useRef(null);
const [schema, setSchema] = useState(null);
const [showGqlDocs, setShowGqlDocs] = useState(false);
const onSchemaLoad = (schema) => setSchema(schema);
const toggleDocs = () => setShowGqlDocs((showGqlDocs) => !showGqlDocs);
const handleGqlClickReference = (reference) => {
const onSchemaLoad = useCallback((schema) => setSchema(schema), []);
const toggleDocs = useCallback(() => setShowGqlDocs((prev) => !prev), []);
const handleGqlClickReference = useCallback((reference) => {
if (docExplorerRef.current) {
docExplorerRef.current.showDocForReference(reference);
}
if (!showGqlDocs) {
setShowGqlDocs(true);
}
};
}, []);
const handleMouseMove = (e) => {
if (dragging && mainSectionRef.current) {
e.preventDefault();
const mainRect = mainSectionRef.current.getBoundingClientRect();
const handleMouseMove = useCallback((e) => {
if (!draggingRef.current || !mainSectionRef.current) return;
if (isVerticalLayout) {
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
return;
}
e.preventDefault();
const mainRect = mainSectionRef.current.getBoundingClientRect();
setTopPaneHeight(newHeight);
} else {
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
return;
}
setLeftPaneWidth(newWidth);
}
if (isVerticalLayoutRef.current) {
const newHeight = e.clientY - mainRect.top;
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
// Clamp to bounds instead of returning early
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
setTopPaneHeight(clampedHeight);
} else {
const newWidth = e.clientX - mainRect.left;
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
// Clamp to bounds instead of returning early
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
setLeftPaneWidth(clampedWidth);
}
};
}, [setTopPaneHeight, setLeftPaneWidth]);
const handleMouseUp = (e) => {
if (dragging) {
const handleMouseUp = useCallback((e) => {
if (draggingRef.current) {
e.preventDefault();
draggingRef.current = false;
setDragging(false);
}
};
}, []);
const handleDragbarMouseDown = (e) => {
const handleDragbarMouseDown = useCallback((e) => {
e.preventDefault();
draggingRef.current = true;
setDragging(true);
};
}, []);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
@@ -134,7 +143,7 @@ const RequestTabPanel = () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [dragging]);
}, [handleMouseUp, handleMouseMove]);
if (!activeTabUid) {
return <WorkspaceHome />;
@@ -204,8 +213,6 @@ const RequestTabPanel = () => {
}
const handleRun = async () => {
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';
const request = item.draft ? item.draft.request : item.request;
if (isGrpcRequest && !request.url) {
@@ -236,7 +243,60 @@ const RequestTabPanel = () => {
}
};
// TODO: reaper, improve selection of panes
const renderQueryUrl = () => {
if (isGrpcRequest) {
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
}
if (isWsRequest) {
return <WsQueryUrl item={item} collection={collection} handleRun={handleRun} />;
}
return <QueryUrl item={item} collection={collection} handleRun={handleRun} />;
};
const renderRequestPane = () => {
switch (item.type) {
case 'graphql-request':
return (
<GraphQLRequestPane
item={item}
collection={collection}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
/>
);
case 'http-request':
return <HttpRequestPane item={item} collection={collection} />;
case 'grpc-request':
return <GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />;
case 'ws-request':
return <WSRequestPane item={item} collection={collection} handleRun={handleRun} />;
default:
return null;
}
};
const renderResponsePane = () => {
switch (item.type) {
case 'grpc-request':
return <GrpcResponsePane item={item} collection={collection} response={item.response} />;
case 'ws-request':
return <WSResponsePane item={item} collection={collection} response={item.response} />;
default:
return <ResponsePane item={item} collection={collection} response={item.response} />;
}
};
const requestPaneStyle = isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
};
return (
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
@@ -244,48 +304,15 @@ const RequestTabPanel = () => {
}`}
>
<div className="pt-3 pb-3 px-4">
{
isGrpcRequest
? <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
: isWsRequest
? <WsQueryUrl item={item} collection={collection} handleRun={handleRun} />
: <QueryUrl item={item} collection={collection} handleRun={handleRun} />
}
{renderQueryUrl()}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane">
<div
className="px-4 h-full"
style={isVerticalLayout ? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
} : {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}}
style={requestPaneStyle}
>
{item.type === 'graphql-request' ? (
<GraphQLRequestPane
item={item}
collection={collection}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
/>
) : null}
{item.type === 'http-request' ? (
<HttpRequestPane item={item} collection={collection} />
) : null}
{isGrpcRequest ? (
<GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
{isWsRequest ? (
<WSRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
{renderRequestPane()}
</div>
</section>
@@ -301,25 +328,7 @@ const RequestTabPanel = () => {
</div>
<section className="response-pane flex-grow overflow-x-auto">
{item.type === 'grpc-request' ? (
<GrpcResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : item.type === 'ws-request' ? (
<WSResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : (
<ResponsePane
item={item}
collection={collection}
response={item.response}
/>
)}
{renderResponsePane()}
</section>
</section>

View File

@@ -16,7 +16,7 @@ const ClearTimeline = ({ collection, item }) => {
return (
<StyledWrapper className="flex items-center">
<button onClick={clearResponse} className="text-link hover:underline" title="Clear Timeline">
<button type="button" onClick={clearResponse} className="text-link hover:underline whitespace-nowrap" title="Clear Timeline">
Clear Timeline
</button>
</StyledWrapper>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { IconLayoutColumns, IconLayoutRows } from '@tabler/icons';
export const IconDockToBottom = () => {
return (
@@ -83,7 +84,7 @@ const ResponseLayoutToggle = ({ children }) => {
return (
<div
role="button"
role={children ? 'button' : undefined}
tabIndex={0}
onClick={toggleOrientation}
title={title}
@@ -93,10 +94,10 @@ const ResponseLayoutToggle = ({ children }) => {
{children ? children : (
<StyledWrapper className="flex items-center w-full">
<button className="p-1">
{orientation === 'horizontal' ? (
<IconDockToBottom />
{orientation === 'vertical' ? (
<IconLayoutColumns size={16} strokeWidth={1.5} />
) : (
<IconDockToRight />
<IconLayoutRows size={16} strokeWidth={1.5} />
)}
</button>
</StyledWrapper>

View File

@@ -1,7 +1,31 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
/* Default: show dropdown, hide buttons */
.actions-dropdown {
display: flex;
}
.actions-buttons {
display: none;
}
/* When any parent has class 'vertical-layout', show buttons and hide dropdown */
.vertical-layout & {
.actions-dropdown {
display: none;
}
.actions-buttons {
display: flex;
align-items: center;
gap: 2px;
}
}
`;
export default StyledWrapper;

View File

@@ -1,16 +1,13 @@
import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react';
import { debounce } from 'lodash';
import React, { useRef, forwardRef } from 'react';
import styled from 'styled-components';
import { IconDots, IconDownload, IconEraser, IconBookmark, IconCopy } from '@tabler/icons';
import { IconDots, IconDownload, IconEraser, IconBookmark, IconCopy, IconLayoutColumns, IconLayoutRows } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import ResponseDownload from '../ResponseDownload';
import ResponseBookmark from '../ResponseBookmark';
import ResponseClear from '../ResponseClear';
import ResponseLayoutToggle, { useResponseLayoutToggle, IconDockToBottom, IconDockToRight } from '../ResponseLayoutToggle';
import ResponseLayoutToggle, { useResponseLayoutToggle } from '../ResponseLayoutToggle';
import ResponseCopy from '../ResponseCopy/index';
import StyledWrapper from '../StyledWrapper';
const PADDING = 48;
import StyledWrapper from './StyledWrapper';
const StyledMenuIcon = styled.button`
display: flex;
@@ -42,66 +39,7 @@ MenuIcon.displayName = 'MenuIcon';
const ResponsePaneActions = ({ item, collection, responseSize }) => {
const { orientation } = useResponseLayoutToggle();
const [showMenu, setShowMenu] = useState(false);
const actionsRef = useRef(null);
const dropdownTippyRef = useRef();
const individualButtonsWidthRef = useRef(null);
const showMenuRef = useRef(showMenu);
const checkSpace = useCallback(() => {
const actionsContainer = actionsRef.current?.parentElement;
const rightSideContainer = actionsContainer?.closest('.right-side-container');
if (!actionsContainer || !rightSideContainer) return;
const currentActionsWidth = actionsContainer.offsetWidth || 0;
// Store individual buttons width when they're visible
if (!showMenuRef.current && currentActionsWidth > 0) {
individualButtonsWidthRef.current = currentActionsWidth;
}
// Calculate siblings total width
let siblingsTotalWidth = 0;
let sibling = actionsContainer.previousElementSibling;
while (sibling) {
siblingsTotalWidth += sibling.offsetWidth || 0;
sibling = sibling.previousElementSibling;
}
const actionsWidth = individualButtonsWidthRef.current || currentActionsWidth;
const requiredWidth = actionsWidth + siblingsTotalWidth + PADDING;
const shouldShowMenu = rightSideContainer.offsetWidth < requiredWidth;
if (showMenuRef.current !== shouldShowMenu) {
showMenuRef.current = shouldShowMenu;
setShowMenu(shouldShowMenu);
}
}, []);
const debouncedCheckSpace = useMemo(
() => debounce(checkSpace, 50),
[checkSpace]
);
useEffect(() => {
showMenuRef.current = showMenu;
}, [showMenu]);
useEffect(() => {
checkSpace();
const rightSideContainer = actionsRef.current?.closest('.right-side-container');
if (!rightSideContainer) return;
const resizeObserver = new ResizeObserver(debouncedCheckSpace);
resizeObserver.observe(rightSideContainer);
return () => {
resizeObserver.disconnect();
debouncedCheckSpace.cancel();
};
}, [item, debouncedCheckSpace]);
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
@@ -118,8 +56,8 @@ const ResponsePaneActions = ({ item, collection, responseSize }) => {
}
return (
<StyledWrapper ref={actionsRef} className="flex items-center gap-2">
{showMenu ? (
<StyledWrapper className="response-pane-actions-wrapper">
<div className="actions-dropdown">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon data-testid="response-actions-menu" />} placement="bottom-end">
{/* Response Copy */}
@@ -166,21 +104,21 @@ const ResponsePaneActions = ({ item, collection, responseSize }) => {
<ResponseLayoutToggle>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
{orientation === 'horizontal' ? <IconDockToBottom /> : <IconDockToRight />}
{orientation === 'vertical' ? <IconLayoutColumns size={16} strokeWidth={1.5} /> : <IconLayoutRows size={16} strokeWidth={1.5} />}
</span>
<span>Change layout</span>
</div>
</ResponseLayoutToggle>
</Dropdown>
) : (
<div className="flex items-center gap-[2px]">
<ResponseCopy item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseDownload item={item} />
<ResponseClear item={item} collection={collection} />
<ResponseLayoutToggle />
</div>
)}
</div>
<div className="actions-buttons flex items-center gap-[2px]">
<ResponseCopy item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseDownload item={item} />
<ResponseClear item={item} collection={collection} />
<ResponseLayoutToggle />
</div>
</StyledWrapper>
);
};

View File

@@ -1,7 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
overflow: hidden;
min-width: 0;
> div:first-child {
overflow: hidden;
min-width: 0;
}
div.tabs {
overflow: hidden;
min-width: 0;
max-width: 100%;
> div:first-child {
overflow: hidden;
min-width: 0;
max-width: 100%;
}
div.tab {
padding: 6px 0px;
border: none;
@@ -9,6 +27,10 @@ const StyledWrapper = styled.div`
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:focus,
&:active,
@@ -27,6 +49,24 @@ const StyledWrapper = styled.div`
}
}
.right-side-container {
min-width: 0;
flex-shrink: 1;
flex-grow: 1;
}
.response-pane-status {
min-width: 0;
flex-shrink: 1;
flex-grow: 0;
}
.response-pane-actions {
min-width: 0;
flex-shrink: 1;
flex-grow: 0;
}
.some-tests-failed {
color: ${(props) => props.theme.colors.text.danger} !important;
}
@@ -38,7 +78,7 @@ const StyledWrapper = styled.div`
.separator {
height: 16px;
border-left: 1px solid ${(props) => props.theme.preferences.sidebar.border};
margin: 0 8px;
margin: 0 8px;
}
`;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -24,6 +24,7 @@ import ClearTimeline from './ClearTimeline/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
import WSMessagesList from './WsResponsePane/WSMessagesList';
import ResponsiveTabs from 'ui/ResponsiveTabs';
const ResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -33,6 +34,7 @@ const ResponsePane = ({ item, collection }) => {
const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
const rightContentRef = useRef(null);
// Initialize format and tab only once when data loads
const { initialFormat, initialTab } = useInitialResponseFormat(item.response?.dataBuffer, item.response?.headers);
@@ -81,6 +83,41 @@ const ResponsePane = ({ item, collection }) => {
return 0;
}
}, [response.size, response.dataBuffer]);
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
const allTabs = useMemo(() => {
return [
{
key: 'response',
label: 'Response',
indicator: null
},
{
key: 'headers',
label: 'Headers',
indicator: responseHeadersCount > 0 ? <sup className="ml-1 font-medium">{responseHeadersCount}</sup> : null
},
{
key: 'timeline',
label: 'Timeline',
indicator: null
},
{
key: 'tests',
label: (
<TestResultsLabel
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>
),
indicator: null
}
];
}, [responseHeadersCount, item.testResults, item.assertionResults, item.preRequestTestResults, item.postResponseTestResults]);
const getTabPanel = (tab) => {
switch (tab) {
@@ -159,79 +196,57 @@ const ResponsePane = ({ item, collection }) => {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.responsePaneTab
});
};
const rightContent = !isLoading ? (
<div ref={rightContentRef} className="flex justify-end items-center right-side-container gap-3">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
)}
{focusedTab?.responsePaneTab === 'response' ? (
<>
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={(newFormat) => {
setSelectedFormat(newFormat);
}}
onPreviewTabSelect={() => {
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
}}
selectedTab={selectedTab}
/>
</>
) : null}
<div className="flex items-center response-pane-status">
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</div>
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
<div className="flex items-center response-pane-actions">
{focusedTab?.responsePaneTab === 'timeline' ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
<ResponsePaneActions item={item} collection={collection} responseSize={responseSize} />
) : null}
</div>
</div>
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center px-4 tabs" role="tablist">
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
Response
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
{responseHeadersCount > 0 && <sup className="ml-1 font-medium">{responseHeadersCount}</sup>}
</div>
<div className={getTabClassname('timeline')} role="tab" onClick={() => selectTab('timeline')}>
Timeline
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
<TestResultsLabel
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>
</div>
{!isLoading ? (
<div className="flex flex-grow justify-end items-center right-side-container">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
)}
{focusedTab?.responsePaneTab === 'response' ? (
<>
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={(newFormat) => {
setSelectedFormat(newFormat);
}}
onPreviewTabSelect={() => {
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
}}
selectedTab={selectedTab}
/>
<div className="separator" />
</>
) : null}
<div className="flex items-center response-pane-status">
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</div>
<div className="separator" />
<div className="flex items-center response-pane-actions">
{focusedTab?.responsePaneTab === 'timeline' ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
<ResponsePaneActions item={item} collection={collection} responseSize={responseSize} />
) : null}
</div>
</div>
) : null}
<div className="px-4">
<ResponsiveTabs
tabs={allTabs}
activeTab={focusedTab.responsePaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={rightContentRef}
/>
</div>
<section
className="flex flex-col min-h-0 relative px-4 pt-3 auto overflow-auto"

View File

@@ -2,12 +2,21 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
&.tabs {
div.more-tabs {
overflow: hidden;
min-width: 0;
> div:first-child {
overflow: hidden;
min-width: 0;
flex-shrink: 1;
}
.more-tabs {
color: var(--color-tab-inactive) !important;
border-bottom: solid 2px transparent;
}
div.tab {
.tab {
display: inline-flex;
align-items: center;
gap: 0.25rem;

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import { IconChevronDown } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const DROPDOWN_WIDTH = 60;
const CALCULATION_DELAY_DEFAULT = 20;
const CALCULATION_DELAY_EXTENDED = 150;
// Compare two tab arrays by their keys
const areTabArraysEqual = (a, b) => {
if (a.length !== b.length) return false;
return a.every((tab, index) => tab.key === b[index].key);
};
const ResponsiveTabs = ({
tabs,
activeTab,
onTabSelect,
rightContent,
rightContentRef,
delayedTabs = []
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
const tabsContainerRef = useRef(null);
const tabRefsMap = useRef({});
const dropdownTippyRef = useRef(null);
const handleTabSelect = useCallback(
(tabKey) => {
onTabSelect(tabKey);
dropdownTippyRef.current?.hide();
},
[onTabSelect]
);
const calculateTabVisibility = useCallback(() => {
const container = tabsContainerRef.current;
if (!container || !tabs.length) return;
const containerWidth = container.offsetWidth;
const rightContentWidth = rightContentRef?.current?.offsetWidth + 20 || 0;
const availableWidth = containerWidth - rightContentWidth - DROPDOWN_WIDTH;
const visible = [];
const overflow = [];
let currentWidth = 0;
for (const tab of tabs) {
const tabElement = tabRefsMap.current[tab.key];
const tabWidth = tabElement?.offsetWidth + 20 || 100;
if (currentWidth + tabWidth <= availableWidth && !overflow.length) {
visible.push(tab);
currentWidth += tabWidth;
} else {
overflow.push(tab);
}
}
// Ensure active tab is always visible
if (!visible.some((t) => t.key === activeTab) && overflow.length) {
const activeTabIndex = overflow.findIndex((t) => t.key === activeTab);
if (activeTabIndex !== -1) {
const [activeTabItem] = overflow.splice(activeTabIndex, 1);
const lastVisible = visible.pop();
if (lastVisible) {
overflow.unshift(lastVisible);
}
visible.push(activeTabItem);
}
}
// Only update state if arrays actually changed (prevents infinite loops)
setVisibleTabs((prev) => (areTabArraysEqual(prev, visible) ? prev : visible));
setOverflowTabs((prev) => (areTabArraysEqual(prev, overflow) ? prev : overflow));
}, [tabs, activeTab, rightContentRef]);
// Recalculate on tab/activeTab changes
useEffect(() => {
const delay = delayedTabs.includes(activeTab) ? CALCULATION_DELAY_EXTENDED : CALCULATION_DELAY_DEFAULT;
const timeoutId = setTimeout(() => {
requestAnimationFrame(calculateTabVisibility);
}, delay);
return () => clearTimeout(timeoutId);
}, [calculateTabVisibility, activeTab, delayedTabs]);
// Recalculate on container resize only (not rightContent to avoid feedback loops)
useEffect(() => {
let frameId = null;
const observer = new ResizeObserver(() => {
if (frameId) {
cancelAnimationFrame(frameId);
}
frameId = requestAnimationFrame(calculateTabVisibility);
});
if (tabsContainerRef.current) {
observer.observe(tabsContainerRef.current);
}
return () => {
if (frameId) {
cancelAnimationFrame(frameId);
}
observer.disconnect();
};
}, [calculateTabVisibility]);
// Clean up stale refs when tabs change
useEffect(() => {
const currentKeys = new Set(tabs.map((t) => t.key));
for (const key of Object.keys(tabRefsMap.current)) {
if (!currentKeys.has(key)) {
delete tabRefsMap.current[key];
}
}
}, [tabs]);
const hiddenStyle = useMemo(
() => ({
visibility: 'hidden',
position: 'absolute',
display: 'flex',
pointerEvents: 'none'
}),
[]
);
const setTabRef = useCallback((el, key) => {
if (el) {
tabRefsMap.current[key] = el;
}
}, []);
const renderTab = (tab, isInDropdown = false) => {
const isActive = tab.key === activeTab;
if (isInDropdown) {
return (
<div
key={tab.key}
role="tab"
aria-selected={isActive}
className={classnames('dropdown-item', { active: isActive })}
onClick={() => handleTabSelect(tab.key)}
>
<span className="flex items-center gap-1">
{tab.label}
{tab.indicator}
</span>
</div>
);
}
return (
<div
key={tab.key}
role="tab"
aria-selected={isActive}
className={classnames('tab select-none', tab.key, { active: isActive })}
onClick={() => handleTabSelect(tab.key)}
>
{tab.label}
{tab.indicator}
</div>
);
};
return (
<StyledWrapper ref={tabsContainerRef} role="tablist" className="tabs flex items-center justify-between gap-6">
<div className="flex items-center">
{/* Hidden tabs for measurement */}
<div style={hiddenStyle}>
{tabs.map((tab) => (
<div
key={tab.key}
ref={(el) => setTabRef(el, tab.key)}
className={classnames('tab select-none', tab.key, { active: tab.key === activeTab })}
>
{tab.label}
{tab.indicator}
</div>
))}
</div>
{/* Visible tabs */}
{visibleTabs.map((tab) => renderTab(tab))}
{/* Overflow dropdown */}
{overflowTabs.length > 0 && (
<Dropdown
placement="bottom-start"
onCreate={(instance) => (dropdownTippyRef.current = instance)}
icon={(
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
<span>More</span>
<IconChevronDown size={14} strokeWidth={2} />
</div>
)}
>
<div style={{ minWidth: '150px' }}>
{overflowTabs.map((tab) => renderTab(tab, true))}
</div>
</Dropdown>
)}
</div>
{rightContent && (
<div className="flex justify-end items-center">
{rightContent}
</div>
)}
</StyledWrapper>
);
};
export default ResponsiveTabs;

View File

@@ -680,7 +680,7 @@ const expectResponseContains = async (page: Page, texts: string[]) => {
// Create a action to click a response action
const clickResponseAction = async (page: Page, actionTestId: string) => {
const actionButton = await page.getByTestId(actionTestId);
const actionButton = await page.getByTestId(actionTestId).first();
if (await actionButton.isVisible()) {
await actionButton.click();
} else {