mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-26 22:25:40 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
223
packages/bruno-app/src/ui/ResponsiveTabs/index.js
Normal file
223
packages/bruno-app/src/ui/ResponsiveTabs/index.js
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user