feat(network-columns-resize)/added resizable columns in network panel (#8265)

This commit is contained in:
sachin-thakur-bruno
2026-06-23 19:17:18 +05:30
committed by GitHub
parent 800ab6fea0
commit 7adbc6b14e
9 changed files with 572 additions and 122 deletions

View File

@@ -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;
}
`;

View File

@@ -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>

View File

@@ -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' }),

View File

@@ -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) {

View File

@@ -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) => {

View 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
};
}

View 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();
});
});
});

View File

@@ -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;

View File

@@ -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();
});
});