Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ describe('ResponseActions', () => {
});
});

describe('buttons-only rendering', () => {
it('renders buttons-only with no testids or dropdown so the tab bar can measure its width', () => {
const root = render({ renderActionButtonsOnly: true });
getByTestId(root, 'forceful-renderred-action-buttons');
expect(query(root, '[aria-label="Copy Response"]')).toBeTruthy();
expect(query(root, '[aria-label="Change Layout"]')).toBeTruthy();
expect(root.querySelector('[aria-label="More actions"]')).toBeNull();
});
});

describe('collapsed kebab menu', () => {
it('renders the "More actions" dropdown trigger, closed by default', () => {
const root = render();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import React, { useCallback } from 'react';
import {
IconCopy,
IconDownload,
IconEraser,
IconLayoutColumns,
IconLayoutRows,
IconDots
} from '@tabler/icons';
import { IconCopy, IconDownload, IconEraser, IconLayoutColumns, IconLayoutRows, IconDots } from '@tabler/icons';
import CopyResponse from './CopyResponse/CopyResponse';
import ClearResponse from './ClearResponse/ClearResponse';
import DownloadResponse from './DownloadResponse/DownloadResponse';
Expand All @@ -29,17 +22,18 @@ interface ResponseActionsProps {
response: RunRequestResponse;
selectedFormat: ResponseBodyFormat;
showPreview: boolean;
renderActionButtonsOnly?: boolean;
Comment thread
arpit-bruno marked this conversation as resolved.
}

const ResponseActions: React.FC<ResponseActionsProps> = ({
orientation,
itemUuid,
response,
selectedFormat,
showPreview
showPreview,
renderActionButtonsOnly = false
}) => {
const getCopyText = useCallback((
): string => {
const getCopyText = useCallback((): string => {
const data = response?.data;
const dataBuffer = response?.base64Data;
// Preview shows the raw data, so copy that as-is.
Expand All @@ -50,12 +44,7 @@ const ResponseActions: React.FC<ResponseActionsProps> = ({
return formatResponse(data, dataBuffer, selectedFormat);
}
return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
}, [
response?.data,
response?.base64Data,
selectedFormat,
showPreview
]);
}, [response?.data, response?.base64Data, selectedFormat, showPreview]);
const copyDisabled = !response.data;
const { copied, copyResponse } = useCopy({
getText: getCopyText,
Expand All @@ -71,7 +60,13 @@ const ResponseActions: React.FC<ResponseActionsProps> = ({

const menuItems: MenuDropdownItem[] = [
{ id: 'copy', label: 'Copy Response', leftSection: IconCopy, disabled: copyDisabled, onClick: copyResponse },
{ id: 'download', label: 'Download Response', leftSection: IconDownload, disabled: downloadDisabled, onClick: onDownload },
{
id: 'download',
label: 'Download Response',
leftSection: IconDownload,
disabled: downloadDisabled,
onClick: onDownload
},
{ id: 'clear', label: 'Clear Response', leftSection: IconEraser, onClick: onClear },
{
id: 'layout',
Expand All @@ -81,6 +76,31 @@ const ResponseActions: React.FC<ResponseActionsProps> = ({
}
];

const actionButtons = (
<>
<CopyResponse copied={copied} onClick={copyResponse} disabled={copyDisabled} />
<DownloadResponse onClick={onDownload} disabled={downloadDisabled} />
<ClearResponse onClick={onClear} />
<ChangeLayout orientation={orientation} handleChangeLayout={onToggleLayout} />
</>
);

// Width probe for the responsive tab bar: it needs the expanded buttons' width to decide whether
// to show them inline before it has committed to expanding. Rendered as buttons-only with no
// testids so tests and queries never confuse this copy with the live actions.
if (renderActionButtonsOnly) {
return (
<StyledWrapper
className="response-pane-actions-wrapper render-action-buttons-only"
aria-hidden="true"
data-testid="forceful-renderred-action-buttons"
inert={true}
>
<div className="actions-buttons">{actionButtons}</div>
</StyledWrapper>
);
}

return (
<StyledWrapper className="response-pane-actions-wrapper" data-testid="response-pane-actions-wrapper">
<div className="actions-dropdown" data-testid="actions-dropdown">
Expand All @@ -91,10 +111,7 @@ const ResponseActions: React.FC<ResponseActionsProps> = ({
</MenuDropdown>
</div>
<div className="actions-buttons" data-testid="actions-buttons">
<CopyResponse copied={copied} onClick={copyResponse} disabled={copyDisabled} />
<DownloadResponse onClick={onDownload} disabled={downloadDisabled} />
<ClearResponse onClick={onClear} />
<ChangeLayout orientation={orientation} handleChangeLayout={onToggleLayout} />
{actionButtons}
</div>
</StyledWrapper>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ export const StyledWrapper = styled.div`
height: 1.25rem;
}

.expandable & {
.expandable &,
&.render-action-buttons-only {
.actions-dropdown {
display: none;
}
.actions-buttons {
display: flex;
align-items: center;
gap: 0.125rem;
display: flex;
align-items: center;
gap: 0.125rem;
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useCallback, useRef, useState } from 'react';

export default function useResponseActions() {
const [actionsExpandedWidth, setActionsExpandedWidth] = useState<number>();
const actionsMeasureObserver = useRef<ResizeObserver | null>(null);
// Track the rendered width of a hidden expanded copy of the actions so the tab bar always has an
// up-to-date figure (it shifts with theme/font, not just at mount) without hardcoding a constant.
// The node is a measurement-only copy, so mark it inert to keep it out of focus/pointer/a11y —
// set imperatively because React 18 doesn't pass the `inert` prop through (see ExampleCard).
const measureActions = useCallback((node: HTMLDivElement | null) => {
actionsMeasureObserver.current?.disconnect();
if (!node) return;
if (typeof ResizeObserver === 'undefined') return;
const update = () => setActionsExpandedWidth(node.offsetWidth || undefined);
update();
actionsMeasureObserver.current = new ResizeObserver(update);
actionsMeasureObserver.current.observe(node);
}, []);

return { actionsExpandedWidth, measureActions };
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
FORMAT_ICONS
} from '@/constants';
import PreviewToggleHeader from './PreviewToggleHeader/PreviewToggleHeader';
import { StyledWrapper } from './StyledWrapper';

interface ResponseFormatSelectorProps {
handleSelection?: (value: ResponseBodyFormat) => void;
Expand Down Expand Up @@ -46,27 +47,28 @@ const ResponseFormatSelector: React.FC<ResponseFormatSelectorProps> = ({
const TriggerIcon = showPreview ? IconEye : selectedFormat ? FORMAT_ICONS[selectedFormat] : undefined;

return (
<MenuDropdown
items={items}
selectedItemId={selectedFormat}
itemToText={(item: MenuDropdownItem) => (
<span className="inline-flex items-center gap-1.5">
{TriggerIcon && (
<TriggerIcon
size={14}
stroke={1.5}
aria-hidden
data-testid="response-format-selector-trigger-icon"
style={{ color: 'var(--oc-brand)' }}
/>
)}
{item.label}
</span>
)}
placement="bottom-end"
header={<PreviewToggleHeader checked={showPreview} onChange={toggleView} />}
testId="response-format-selector"
/>
<StyledWrapper>
<MenuDropdown
items={items}
selectedItemId={selectedFormat}
itemToText={(item: MenuDropdownItem) => (
<span className="inline-flex items-center gap-1.5">
{TriggerIcon && (
<TriggerIcon
size={14}
stroke={1.5}
aria-hidden
data-testid="response-format-selector-trigger-icon"
/>
)}
{item.label}
</span>
)}
placement="bottom-end"
header={<PreviewToggleHeader checked={showPreview} onChange={toggleView} />}
testId="response-format-selector"
/>
</StyledWrapper>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import styled from '@emotion/styled';

export const StyledWrapper = styled.div`
.menu-dropdown-trigger {
&:hover {
border-color: var(--oc-colors-text-subtext2);
}

.menu-dropdown-trigger-label svg {
color: var(--oc-brand);
}
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { RunRequestResponse } from '@/runner';
import ResponseStatus from './ResponseInfo/ResponseStatus/ResponseStatus';
import ResponseSize from './ResponseInfo/ResponseSize/ResponseSize';
import ResponseActions from './ResponseActions/ResponseActions';
import { RESPONSE_ACTIONS_EXPANDED_WIDTH } from '@/constants/response';
import useResponseActions from './ResponseActions/hooks/useResponseActions';

interface ResponsePaneProps {
response: RunRequestResponse;
Expand All @@ -25,6 +25,8 @@ interface ResponsePaneProps {

const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orientation, itemUuid }) => {
const [activeTab, setActiveTab] = useState('response');
const { actionsExpandedWidth, measureActions } = useResponseActions();

const {
selectedFormat,
showPreview,
Expand Down Expand Up @@ -110,11 +112,14 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orient
}
];

// The status metadata and the actions are separate direct children of the tab bar's right slot so
// the responsive tab bar can measure the actions block (the last child) to decide whether to show
// it as inline buttons or collapse it into a menu.
// MUST stay a fragment: the format selector, status metadata, and actions have to be *direct*
// children of the tab bar's right slot. The responsive tab bar measures the leading children live
// and swaps in a supplied width only for the actions (the last child) to choose inline buttons vs.
// a collapsed menu. Wrapping these in a container collapses them into one child, so that model
// discards the format/status widths and the actions stop collapsing (see useResponsiveTabs). To
// adjust spacing between the groups, set `.tabs-right { gap }` in this pane's StyledWrapper.
const statusInfo = (
<div className="flex items-center gap-3 flex-wrap text-xs">
<>
{activeTab === 'response' && (
<ResponseFormatSelector
selectedFormat={selectedFormat}
Expand All @@ -124,7 +129,7 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orient
toggleView={toggleView}
/>
)}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap text-xs">
<ResponseStatus status={response.status} statusText={response.statusText} />
<ResponseDuration duration={response.duration} />
<ResponseSize size={response.size} />
Expand All @@ -136,20 +141,32 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orient
selectedFormat={selectedFormat}
showPreview={showPreview}
/>
</div>
</>
);

return (
<StyledWrapper>
{!response.error && (
<div className="response-actions-measure" aria-hidden="true" ref={measureActions} inert>
<ResponseActions
renderActionButtonsOnly
orientation={orientation}
itemUuid={itemUuid}
response={response}
selectedFormat={selectedFormat}
showPreview={showPreview}
/>
</div>
)}
<Tabs
variant="responsive"
testId="response-tabs"
className="h-full"
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
rightElement={response.error ? undefined : statusInfo}
rightContentExpandedWidth={RESPONSE_ACTIONS_EXPANDED_WIDTH}
rightElement={response.error ? null : statusInfo}
rightContentExpandedWidth={actionsExpandedWidth}
/>
</StyledWrapper>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ export const StyledWrapper = styled.div`
overflow-y: auto;
}

.tabs-right {
gap: 0.75rem;
}

.response-actions-measure {
position: absolute;
top: 0;
left: 0;
visibility: hidden;
pointer-events: none;
}

& .send-icon {
padding: 0.5625rem;
border-radius: 50%;
Expand Down
4 changes: 0 additions & 4 deletions packages/bruno-api-docs/src/constants/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,3 @@ export const FORMAT_ICONS: Record<ResponseBodyFormat, TablerIcon> = {
hex: IconHexagons,
base64: IconBinaryTree
};

// Width the actions block occupies when shown as buttons; the responsive tab bar uses it to
// decide whether to expand the actions inline or collapse them into a menu.
export const RESPONSE_ACTIONS_EXPANDED_WIDTH = 302;
20 changes: 9 additions & 11 deletions packages/bruno-api-docs/src/ui/Tabs/useResponsiveTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,11 @@ export const useResponsiveTabs = (
let rightWidth = rightEl?.offsetWidth ?? 0;
let rightModeled = false;
if (rightContentExpandedWidth != null && rightEl != null && rightEl.children.length > 0) {
const children = rightEl.children;
const expandableIndex = children.length - 1;
let modeled = 0;
for (let i = 0; i < children.length; i += 1) {
modeled += i === expandableIndex ? rightContentExpandedWidth : (children[i] as HTMLElement).offsetWidth;
}
rightWidth = modeled;
// The trailing child (e.g. an actions block) can collapse to a compact form, so its live width
// understates the space it needs when expanded. Swap only that child's contribution for the
// supplied expanded width, taken from the slot's real width so inter-child gaps still count.
const expandable = rightEl.children[rightEl.children.length - 1] as HTMLElement;
rightWidth = rightEl.offsetWidth - expandable.offsetWidth + rightContentExpandedWidth;
rightModeled = true;
}

Expand Down Expand Up @@ -129,9 +127,9 @@ export const useResponsiveTabs = (

const idsKey = ids.join(',');

// Recompute when responsiveness toggles, the tab set changes, or the active tab
// changes. idsKey/activeId are read through refs inside recalcRef, so they are
// listed here purely to re-run the measurement rather than referenced directly.
// Recompute when responsiveness toggles, the tab set changes, the active tab changes, or the
// supplied right-content expanded width updates. idsKey/activeId are read through refs inside
// recalcRef, so they are listed here purely to re-run the measurement rather than referenced directly.
useEffect(() => {
if (!enabled) {
setVisibleIds((prev) => (sameOrder(prev, idsRef.current) ? prev : idsRef.current));
Expand All @@ -141,7 +139,7 @@ export const useResponsiveTabs = (
}
const frame = requestAnimationFrame(() => recalcRef.current());
return () => cancelAnimationFrame(frame);
}, [enabled, idsKey, activeId]);
}, [enabled, idsKey, activeId, rightContentExpandedWidth]);

// Observe only the container — the source of the available-width budget. Deliberately NOT the
// right slot: its width is subtracted from that budget, but the trailing actions block collapses
Expand Down
Loading