mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 07:34:07 +00:00
feat(network-columns-resize)/added resizable columns in network panel (#8265)
This commit is contained in:
committed by
GitHub
parent
800ab6fea0
commit
7adbc6b14e
@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.network-empty {
|
||||
@@ -68,47 +68,40 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.col-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${(props) => props.theme.console.border};
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
&.is-resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 10px;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
|
||||
& > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
&:first-child { padding-left: 16px; }
|
||||
&:last-child { padding-right: 16px; }
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
@@ -120,10 +113,7 @@ const StyledWrapper = styled.div`
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
svg { flex-shrink: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,48 +121,70 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0; /* Important for proper scrolling */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.request-row {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
& > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:hover { background: ${(props) => props.theme.console.logHoverBg}; }
|
||||
|
||||
&.selected {
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.request-method {
|
||||
padding: 2px 8px 2px 16px;
|
||||
.col-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
transform: translateX(-2px);
|
||||
cursor: col-resize;
|
||||
z-index: 3;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.sidebar.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&.resizing::after {
|
||||
background: ${(props) => props.theme.sidebar.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.request-status {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.request-method { padding: 2px 8px 2px 16px; }
|
||||
.request-status { padding: 2px 8px; }
|
||||
|
||||
.method-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 45px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
.status-badge { font-size: ${(props) => props.theme.font.size.sm}; }
|
||||
|
||||
.request-domain {
|
||||
padding: 2px 8px;
|
||||
@@ -196,6 +208,9 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-duration {
|
||||
@@ -204,11 +219,12 @@ const StyledWrapper = styled.div`
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.request-size {
|
||||
padding: 2px 8px;
|
||||
@@ -216,6 +232,9 @@ const StyledWrapper = styled.div`
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconNetwork,
|
||||
@@ -8,17 +9,17 @@ import {
|
||||
import {
|
||||
setSelectedRequest
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import { useResizableColumns } from 'hooks/useResizableColumns';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils';
|
||||
import { sortRequests } from './utils';
|
||||
|
||||
// TODO: Columns will be resizable in the future, so width can be null (for auto) or a number (for fixed width)
|
||||
const COLUMNS = [
|
||||
{ key: 'method', label: 'Method', width: 90, align: 'left' },
|
||||
{ key: 'status', label: 'Status', width: 80, align: 'left' },
|
||||
{ key: 'domain', label: 'Domain', width: 200, align: 'left' },
|
||||
{ key: 'path', label: 'Path', width: null, align: 'left' },
|
||||
{ key: 'time', label: 'Time', width: 100, align: 'left' },
|
||||
{ key: 'duration', label: 'Duration', width: 120, align: 'right' },
|
||||
{ key: 'method', label: 'Method', width: 80, align: 'left' },
|
||||
{ key: 'status', label: 'Status', width: 70, align: 'left' },
|
||||
{ key: 'domain', label: 'Domain', width: 180, align: 'left' },
|
||||
{ key: 'path', label: 'Path', width: 300, align: 'left' },
|
||||
{ key: 'time', label: 'Time', width: 110, align: 'left' },
|
||||
{ key: 'duration', label: 'Duration', width: 100, align: 'right' },
|
||||
{ key: 'size', label: 'Size', width: 80, align: 'right' }
|
||||
];
|
||||
|
||||
@@ -133,15 +134,27 @@ const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
|
||||
|
||||
const NetworkTab = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
|
||||
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
|
||||
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
|
||||
const [sortConfig, setSortConfig] = usePersistedState({ key: 'devtools-network-sort', default: { key: null, direction: null } });
|
||||
const [savedColWidths, setSavedColWidths] = usePersistedState({ key: 'devtools-network-col-widths', default: null });
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
gridTemplateColumns,
|
||||
separatorPositions,
|
||||
resizingIdx,
|
||||
handleResizeStart
|
||||
} = useResizableColumns({
|
||||
defaultWidths: COLUMNS.map((c) => c.width),
|
||||
initialWidths: savedColWidths,
|
||||
minColWidth: 60,
|
||||
onResizeEnd: setSavedColWidths
|
||||
});
|
||||
|
||||
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
const allRequests = useMemo(() => {
|
||||
const requests = [];
|
||||
|
||||
collections.forEach((collection) => {
|
||||
if (collection.timeline) {
|
||||
collection.timeline
|
||||
@@ -155,7 +168,6 @@ const NetworkTab = () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return requests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [collections]);
|
||||
|
||||
@@ -166,15 +178,11 @@ const NetworkTab = () => {
|
||||
});
|
||||
}, [allRequests, networkFilters]);
|
||||
|
||||
const handleRequestClick = (request) => {
|
||||
dispatch(setSelectedRequest(request));
|
||||
};
|
||||
const handleRequestClick = (request) => dispatch(setSelectedRequest(request));
|
||||
|
||||
const handleHeaderClick = (key) => {
|
||||
setSortConfig((prev) => {
|
||||
// If clicking a different column, start with ascending sort
|
||||
if (prev.key !== key) return { key, direction: 'asc' };
|
||||
|
||||
if (prev.direction === 'asc') return { key, direction: 'desc' };
|
||||
return { key: null, direction: null };
|
||||
});
|
||||
@@ -195,7 +203,7 @@ const NetworkTab = () => {
|
||||
<span>Requests will appear here as you make API calls</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="requests-container">
|
||||
<div className={`requests-container${resizingIdx !== null ? ' is-resizing' : ''}`}>
|
||||
<div className="requests-header" style={{ gridTemplateColumns }}>
|
||||
{COLUMNS.map((col) => (
|
||||
<div
|
||||
@@ -214,27 +222,30 @@ const NetworkTab = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
<div ref={containerRef} className="requests-list">
|
||||
{sortedRequests.map((request, index) => (
|
||||
<RequestRow
|
||||
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
|
||||
request={request}
|
||||
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
|
||||
isSelected={
|
||||
selectedRequest?.timestamp === request.timestamp
|
||||
&& selectedRequest?.itemUid === request.itemUid
|
||||
}
|
||||
onClick={() => handleRequestClick(request)}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{separatorPositions.map((pos, i) =>
|
||||
pos ? (
|
||||
<div
|
||||
key={i}
|
||||
className="col-separator"
|
||||
style={'left' in pos ? { left: `${pos.left}px` } : { right: `${pos.right}px` }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{separatorPositions.map((left, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`col-separator${resizingIdx === i ? ' resizing' : ''}`}
|
||||
style={{ left }}
|
||||
onMouseDown={(e) => handleResizeStart(e, i)}
|
||||
data-testid={`network-col-separator-${i}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,10 @@ const makeRequest = (overrides = {}) => ({
|
||||
|
||||
const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const renderNetworkTab = (requests = []) => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
@@ -55,6 +59,10 @@ const renderNetworkTab = (requests = []) => {
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('sort state cycle', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: 'a', method: 'GET' }),
|
||||
@@ -163,6 +171,26 @@ describe('sort results', () => {
|
||||
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
|
||||
});
|
||||
|
||||
it('restores sort config after close and reopen', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
|
||||
// First mount — set sort to method descending
|
||||
const { unmount } = renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method')); // asc
|
||||
fireEvent.click(screen.getByTestId('network-header-method')); // desc
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
unmount(); // simulate closing devtools
|
||||
|
||||
// Second mount — sort should be restored from localStorage
|
||||
renderNetworkTab(requests);
|
||||
expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
});
|
||||
|
||||
it('preserves insertion order when sort is cleared', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
export const getGridTemplate = (columns) =>
|
||||
columns.map((c) => (c.width ? `${c.width}px` : '1fr')).join(' ');
|
||||
|
||||
export const getSeparatorPositions = (columns) => {
|
||||
const n = columns.length;
|
||||
const positions = new Array(n - 1).fill(null);
|
||||
|
||||
let leftOffset = 0;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (columns[i].width === null) break;
|
||||
leftOffset += columns[i].width;
|
||||
positions[i] = { left: leftOffset };
|
||||
}
|
||||
|
||||
let rightOffset = 0;
|
||||
for (let i = n - 1; i > 0; i--) {
|
||||
if (columns[i].width === null) break;
|
||||
rightOffset += columns[i].width;
|
||||
if (positions[i - 1] === null) {
|
||||
positions[i - 1] = { right: rightOffset };
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
};
|
||||
|
||||
export const getSortValue = (request, key) => {
|
||||
const { request: req, response: res, timestamp } = request.data;
|
||||
switch (key) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ReactJson from 'react-json-view';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -23,8 +24,7 @@ import {
|
||||
setActiveTab,
|
||||
clearDebugErrors,
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters,
|
||||
updateRequestDetailsPanelWidth
|
||||
toggleAllNetworkFilters
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
|
||||
import NetworkTab from './NetworkTab';
|
||||
@@ -386,7 +386,7 @@ const Console = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const savedDetailsPanelWidth = useSelector((state) => state.logs.requestDetailsPanelWidth);
|
||||
const [savedDetailsPanelWidth, setSavedDetailsPanelWidth] = usePersistedState({ key: 'devtools-details-panel-width', default: 400 });
|
||||
const consoleRef = useRef(null);
|
||||
|
||||
const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({
|
||||
@@ -394,7 +394,7 @@ const Console = () => {
|
||||
minWidth: MIN_DETAILS_PANEL_WIDTH,
|
||||
maxWidth: MAX_DETAILS_PANEL_WIDTH,
|
||||
direction: 'right',
|
||||
onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth }))
|
||||
onResizeEnd: (newWidth) => setSavedDetailsPanelWidth(newWidth)
|
||||
});
|
||||
|
||||
const logCounts = logs.reduce((counts, log) => {
|
||||
|
||||
162
packages/bruno-app/src/hooks/useResizableColumns/index.js
Normal file
162
packages/bruno-app/src/hooks/useResizableColumns/index.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Drag-to-resize behavior for a multi-column grid.
|
||||
*
|
||||
* Columns always sum to the container width (no horizontal scroll).
|
||||
* Dragging a separator adjusts only the two adjacent columns (zero-sum).
|
||||
* Either column hitting minColWidth causes a hard stop.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {number[]} options.defaultWidths - Default px width for each column (used as proportions)
|
||||
* @param {number[]|null} [options.initialWidths] - Persisted widths to restore; falls back to defaultWidths
|
||||
* @param {number} [options.minColWidth] - Minimum column width in px (default: 60)
|
||||
* @param {function} [options.onResizeEnd] - Called with final colWidths array after a drag ends
|
||||
*
|
||||
* @returns {{
|
||||
* containerRef: React.RefObject,
|
||||
* colWidths: number[] | null,
|
||||
* gridTemplateColumns: string,
|
||||
* separatorPositions: number[],
|
||||
* resizingIdx: number | null,
|
||||
* handleResizeStart: (e: MouseEvent, separatorIdx: number) => void
|
||||
* }}
|
||||
*/
|
||||
|
||||
const getGridTemplate = (widths) => widths.map((w) => `${w}px`).join(' ');
|
||||
|
||||
const getSeparatorPositions = (widths) => {
|
||||
const positions = [];
|
||||
let left = 0;
|
||||
for (let i = 0; i < widths.length - 1; i++) {
|
||||
left += widths[i];
|
||||
positions.push(left);
|
||||
}
|
||||
return positions;
|
||||
};
|
||||
|
||||
const scaleWidthsToTotal = (widths, targetTotal, minColWidth) => {
|
||||
// When the container is too narrow to satisfy minColWidth for every column,
|
||||
// fall back to equal distribution so the total stays exact (columns clip instead of overflow).
|
||||
if (targetTotal < minColWidth * widths.length) {
|
||||
const each = Math.floor(targetTotal / widths.length);
|
||||
const last = targetTotal - each * (widths.length - 1);
|
||||
return [...Array(widths.length - 1).fill(each), last];
|
||||
}
|
||||
|
||||
const currentTotal = widths.reduce((s, w) => s + w, 0);
|
||||
const factor = targetTotal / currentTotal;
|
||||
const next = widths.slice(0, -1).map((w) => Math.max(minColWidth, Math.round(w * factor)));
|
||||
const last = Math.max(minColWidth, targetTotal - next.reduce((s, w) => s + w, 0));
|
||||
return [...next, last];
|
||||
};
|
||||
|
||||
export function useResizableColumns({ defaultWidths, initialWidths = null, minColWidth = 60, onResizeEnd = null }) {
|
||||
const [colWidths, setColWidths] = useState(null);
|
||||
const [resizingIdx, setResizingIdx] = useState(null);
|
||||
const dragCleanupRef = useRef(null);
|
||||
const observerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dragCleanupRef.current?.();
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const gridTemplateColumns = useMemo(
|
||||
() => colWidths ? getGridTemplate(colWidths) : `repeat(${defaultWidths.length}, 1fr)`,
|
||||
[colWidths, defaultWidths.length]
|
||||
);
|
||||
|
||||
const separatorPositions = useMemo(
|
||||
() => colWidths ? getSeparatorPositions(colWidths) : [],
|
||||
[colWidths]
|
||||
);
|
||||
|
||||
// Callback ref: attaches the ResizeObserver whenever the element mounts,
|
||||
// even if it renders conditionally after the initial mount.
|
||||
const containerRef = useCallback((node) => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
|
||||
if (!node) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const newWidth = Math.floor(entries[0].contentRect.width);
|
||||
if (!newWidth) return;
|
||||
|
||||
setColWidths((prev) => {
|
||||
if (!prev) {
|
||||
return scaleWidthsToTotal(initialWidths ?? defaultWidths, newWidth, minColWidth);
|
||||
}
|
||||
const oldTotal = prev.reduce((s, w) => s + w, 0);
|
||||
if (Math.abs(oldTotal - newWidth) <= 1) return prev;
|
||||
return scaleWidthsToTotal(prev, newWidth, minColWidth);
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback((e, separatorIdx) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidths = [...colWidths];
|
||||
|
||||
setResizingIdx(separatorIdx);
|
||||
|
||||
const onMouseMove = (moveE) => {
|
||||
const delta = moveE.clientX - startX;
|
||||
|
||||
// Hard stop: clamp so neither adjacent column goes below minColWidth
|
||||
const clampedDelta = Math.max(
|
||||
-(startWidths[separatorIdx] - minColWidth),
|
||||
Math.min(
|
||||
startWidths[separatorIdx + 1] - minColWidth,
|
||||
delta
|
||||
)
|
||||
);
|
||||
|
||||
const next = [...startWidths];
|
||||
next[separatorIdx] = startWidths[separatorIdx] + clampedDelta;
|
||||
next[separatorIdx + 1] = startWidths[separatorIdx + 1] - clampedDelta;
|
||||
|
||||
setColWidths(next);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
dragCleanupRef.current = null;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setResizingIdx(null);
|
||||
cleanup();
|
||||
// Capture final widths for persistence — read directly from state via functional update
|
||||
if (onResizeEnd) {
|
||||
setColWidths((current) => {
|
||||
onResizeEnd(current);
|
||||
return current;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
dragCleanupRef.current = cleanup;
|
||||
}, [colWidths, minColWidth]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
colWidths,
|
||||
gridTemplateColumns,
|
||||
separatorPositions,
|
||||
resizingIdx,
|
||||
handleResizeStart
|
||||
};
|
||||
}
|
||||
240
packages/bruno-app/src/hooks/useResizableColumns/index.spec.js
Normal file
240
packages/bruno-app/src/hooks/useResizableColumns/index.spec.js
Normal file
@@ -0,0 +1,240 @@
|
||||
const { describe, it, expect, jest, beforeEach } = require('@jest/globals');
|
||||
import { render, act } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useResizableColumns } from './index';
|
||||
|
||||
const CONTAINER_WIDTH = 1000;
|
||||
const DEFAULT_WIDTHS = [100, 200, 400, 200, 100]; // sums to CONTAINER_WIDTH
|
||||
const MIN_COL_WIDTH = 60;
|
||||
|
||||
// Captures the latest hook return value on each render
|
||||
let hookValue;
|
||||
|
||||
function Fixture({ defaultWidths = DEFAULT_WIDTHS, minColWidth = MIN_COL_WIDTH }) {
|
||||
const hook = useResizableColumns({ defaultWidths, minColWidth });
|
||||
hookValue = hook;
|
||||
return <div ref={hook.containerRef} />;
|
||||
}
|
||||
|
||||
const setup = (props = {}) => render(<Fixture {...props} />);
|
||||
|
||||
const fireMouse = (type, clientX) => {
|
||||
act(() => {
|
||||
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const startDrag = (separatorIdx, startX) => {
|
||||
act(() => {
|
||||
hookValue.handleResizeStart(
|
||||
{ clientX: startX, preventDefault: jest.fn(), stopPropagation: jest.fn() },
|
||||
separatorIdx
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
describe('useResizableColumns', () => {
|
||||
let triggerResize;
|
||||
|
||||
beforeEach(() => {
|
||||
hookValue = null;
|
||||
global.ResizeObserver = class {
|
||||
constructor(cb) {
|
||||
triggerResize = (w) => cb([{ contentRect: { width: w } }]);
|
||||
}
|
||||
|
||||
observe() {
|
||||
triggerResize(CONTAINER_WIDTH);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
describe('initial state (before measurement)', () => {
|
||||
beforeEach(() => {
|
||||
global.ResizeObserver = class {
|
||||
constructor() {}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
setup();
|
||||
});
|
||||
|
||||
it('returns fallback state before measurement', () => {
|
||||
expect(hookValue.colWidths).toBeNull();
|
||||
expect(hookValue.gridTemplateColumns)
|
||||
.toBe(`repeat(${DEFAULT_WIDTHS.length}, 1fr)`);
|
||||
expect(hookValue.separatorPositions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('container measurement', () => {
|
||||
it('columns sum to container width after first measurement', () => {
|
||||
setup();
|
||||
const total = hookValue.colWidths.reduce((s, w) => s + w, 0);
|
||||
expect(total).toBe(CONTAINER_WIDTH);
|
||||
});
|
||||
|
||||
it('falls back to equal distribution when container is narrower than minColWidth * columns', () => {
|
||||
// 5 columns × 60px min = 300px minimum; container at 200px is unsatisfiable
|
||||
setup();
|
||||
act(() => { triggerResize(200); });
|
||||
const total = hookValue.colWidths.reduce((s, w) => s + w, 0);
|
||||
expect(total).toBe(200);
|
||||
});
|
||||
|
||||
it('ignores a zero-width measurement', () => {
|
||||
global.ResizeObserver = class {
|
||||
constructor(cb) {
|
||||
triggerResize = (w) => cb([{ contentRect: { width: w } }]);
|
||||
}
|
||||
|
||||
observe() {
|
||||
triggerResize(0);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
setup();
|
||||
|
||||
expect(hookValue.colWidths).toBeNull();
|
||||
});
|
||||
|
||||
it('scales existing widths proportionally on container resize', () => {
|
||||
setup();
|
||||
|
||||
act(() => {
|
||||
triggerResize(800);
|
||||
});
|
||||
|
||||
const total = hookValue.colWidths.reduce((s, w) => s + w, 0);
|
||||
|
||||
expect(total).toBe(800);
|
||||
});
|
||||
|
||||
it('preserves manually-resized proportions when container resizes again', () => {
|
||||
setup();
|
||||
|
||||
startDrag(1, 500);
|
||||
fireMouse('mousemove', 600);
|
||||
fireMouse('mouseup', 600);
|
||||
|
||||
const userWidths = [...hookValue.colWidths];
|
||||
|
||||
act(() => {
|
||||
triggerResize(1500);
|
||||
});
|
||||
|
||||
expect(
|
||||
hookValue.colWidths.reduce((s, w) => s + w, 0)
|
||||
).toBe(1500);
|
||||
|
||||
expect(hookValue.colWidths[1]).toBeCloseTo(
|
||||
userWidths[1] * 1.5,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag resize', () => {
|
||||
beforeEach(() => {
|
||||
setup();
|
||||
});
|
||||
|
||||
it('dragging right grows left column and shrinks right column equally', () => {
|
||||
const before = [...hookValue.colWidths];
|
||||
|
||||
startDrag(1, 500);
|
||||
fireMouse('mousemove', 550);
|
||||
|
||||
expect(hookValue.colWidths[1]).toBe(before[1] + 50);
|
||||
expect(hookValue.colWidths[2]).toBe(before[2] - 50);
|
||||
});
|
||||
|
||||
it('dragging left shrinks left column and grows right column equally', () => {
|
||||
const before = [...hookValue.colWidths];
|
||||
|
||||
startDrag(1, 500);
|
||||
fireMouse('mousemove', 450);
|
||||
|
||||
expect(hookValue.colWidths[1]).toBe(before[1] - 50);
|
||||
expect(hookValue.colWidths[2]).toBe(before[2] + 50);
|
||||
});
|
||||
|
||||
it('only the two adjacent columns change', () => {
|
||||
const before = [...hookValue.colWidths];
|
||||
|
||||
startDrag(1, 500);
|
||||
fireMouse('mousemove', 550);
|
||||
|
||||
expect(hookValue.colWidths[0]).toBe(before[0]);
|
||||
expect(hookValue.colWidths[3]).toBe(before[3]);
|
||||
expect(hookValue.colWidths[4]).toBe(before[4]);
|
||||
});
|
||||
|
||||
it('hard stop: left column clamps at minColWidth', () => {
|
||||
startDrag(0, 500);
|
||||
fireMouse('mousemove', 100);
|
||||
|
||||
expect(hookValue.colWidths[0]).toBe(MIN_COL_WIDTH);
|
||||
});
|
||||
|
||||
it('hard stop: right neighbor absorbs exactly the full available delta', () => {
|
||||
startDrag(0, 500);
|
||||
fireMouse('mousemove', 100);
|
||||
|
||||
expect(hookValue.colWidths[1]).toBe(
|
||||
DEFAULT_WIDTHS[0] + DEFAULT_WIDTHS[1] - MIN_COL_WIDTH
|
||||
);
|
||||
});
|
||||
|
||||
it('hard stop: right column clamps at minColWidth', () => {
|
||||
startDrag(0, 500);
|
||||
fireMouse('mousemove', 900);
|
||||
|
||||
expect(hookValue.colWidths[1]).toBe(MIN_COL_WIDTH);
|
||||
});
|
||||
|
||||
it('hard stop: left neighbor absorbs exactly the full available delta', () => {
|
||||
startDrag(0, 500);
|
||||
fireMouse('mousemove', 900);
|
||||
|
||||
expect(hookValue.colWidths[0]).toBe(
|
||||
DEFAULT_WIDTHS[0] + DEFAULT_WIDTHS[1] - MIN_COL_WIDTH
|
||||
);
|
||||
});
|
||||
|
||||
it('resizingIdx tracks active separator and clears on mouseup', () => {
|
||||
startDrag(2, 500);
|
||||
|
||||
expect(hookValue.resizingIdx).toBe(2);
|
||||
|
||||
fireMouse('mouseup', 500);
|
||||
|
||||
expect(hookValue.resizingIdx).toBeNull();
|
||||
});
|
||||
|
||||
it('stops updating after mouseup', () => {
|
||||
startDrag(1, 500);
|
||||
fireMouse('mousemove', 550);
|
||||
|
||||
const widthAfterDrag = hookValue.colWidths[1];
|
||||
|
||||
fireMouse('mouseup', 550);
|
||||
fireMouse('mousemove', 700);
|
||||
|
||||
expect(hookValue.colWidths[1]).toBe(widthAfterDrag);
|
||||
});
|
||||
|
||||
it('unmounting during an active drag does not throw', () => {
|
||||
const { unmount } = setup();
|
||||
|
||||
startDrag(1, 500);
|
||||
fireMouse('mousemove', 600);
|
||||
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ export const TABS = {
|
||||
|
||||
export const TAB_IDENFIERS = Object.values(TABS);
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
const MAX_DEBUG_ERRORS = 500;
|
||||
|
||||
const initialState = {
|
||||
logs: [],
|
||||
debugErrors: [],
|
||||
@@ -31,10 +34,7 @@ const initialState = {
|
||||
OPTIONS: true
|
||||
},
|
||||
selectedRequest: null,
|
||||
selectedError: null,
|
||||
maxLogs: 1000,
|
||||
maxDebugErrors: 500,
|
||||
requestDetailsPanelWidth: 400
|
||||
selectedError: null
|
||||
};
|
||||
|
||||
export const logsSlice = createSlice({
|
||||
@@ -53,8 +53,8 @@ export const logsSlice = createSlice({
|
||||
|
||||
state.logs.push(newLog);
|
||||
|
||||
if (state.logs.length > state.maxLogs) {
|
||||
state.logs = state.logs.slice(-state.maxLogs);
|
||||
if (state.logs.length > MAX_LOGS) {
|
||||
state.logs = state.logs.slice(-MAX_LOGS);
|
||||
}
|
||||
},
|
||||
addDebugError: (state, action) => {
|
||||
@@ -72,8 +72,8 @@ export const logsSlice = createSlice({
|
||||
|
||||
state.debugErrors.push(newError);
|
||||
|
||||
if (state.debugErrors.length > state.maxDebugErrors) {
|
||||
state.debugErrors = state.debugErrors.slice(-state.maxDebugErrors);
|
||||
if (state.debugErrors.length > MAX_DEBUG_ERRORS) {
|
||||
state.debugErrors = state.debugErrors.slice(-MAX_DEBUG_ERRORS);
|
||||
}
|
||||
},
|
||||
clearLogs: (state) => {
|
||||
@@ -90,12 +90,6 @@ export const logsSlice = createSlice({
|
||||
},
|
||||
setActiveTab: (state, action) => {
|
||||
state.activeTab = action.payload;
|
||||
if (action.payload !== 'network') {
|
||||
state.selectedRequest = null;
|
||||
}
|
||||
if (action.payload !== 'debug') {
|
||||
state.selectedError = null;
|
||||
}
|
||||
},
|
||||
updateFilter: (state, action) => {
|
||||
const { filterType, enabled } = action.payload;
|
||||
@@ -128,9 +122,6 @@ export const logsSlice = createSlice({
|
||||
},
|
||||
clearSelectedError: (state) => {
|
||||
state.selectedError = null;
|
||||
},
|
||||
updateRequestDetailsPanelWidth: (state, action) => {
|
||||
state.requestDetailsPanelWidth = action.payload.requestDetailsPanelWidth;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -150,8 +141,7 @@ export const {
|
||||
setSelectedRequest,
|
||||
clearSelectedRequest,
|
||||
setSelectedError,
|
||||
clearSelectedError,
|
||||
updateRequestDetailsPanelWidth
|
||||
clearSelectedError
|
||||
} = logsSlice.actions;
|
||||
|
||||
export default logsSlice.reducer;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import reducer, { setActiveTab, setSelectedRequest, clearSelectedRequest } from './logs';
|
||||
|
||||
const mockRequest = { itemUid: 'item-1', timestamp: 1000, data: {} };
|
||||
|
||||
describe('setActiveTab', () => {
|
||||
it('preserves selectedRequest when switching away from network tab', () => {
|
||||
const stateWithRequest = reducer(undefined, setSelectedRequest(mockRequest));
|
||||
expect(stateWithRequest.selectedRequest).toEqual(mockRequest);
|
||||
|
||||
const stateAfterSwitch = reducer(stateWithRequest, setActiveTab('console'));
|
||||
expect(stateAfterSwitch.selectedRequest).toEqual(mockRequest);
|
||||
});
|
||||
|
||||
it('preserves selectedRequest when switching back to network tab', () => {
|
||||
let state = reducer(undefined, setSelectedRequest(mockRequest));
|
||||
state = reducer(state, setActiveTab('console'));
|
||||
state = reducer(state, setActiveTab('network'));
|
||||
expect(state.selectedRequest).toEqual(mockRequest);
|
||||
});
|
||||
|
||||
it('clears selectedRequest only when explicitly dispatching clearSelectedRequest', () => {
|
||||
let state = reducer(undefined, setSelectedRequest(mockRequest));
|
||||
state = reducer(state, clearSelectedRequest());
|
||||
expect(state.selectedRequest).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user