feat(dev-tools)/adds sorting on columns with verticle borders (#8238)

This commit is contained in:
sachin-thakur-bruno
2026-06-12 17:58:22 +05:30
committed by GitHub
parent 7a24b1924d
commit e7e6cdfa51
5 changed files with 382 additions and 20 deletions

View File

@@ -1,3 +1,19 @@
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn()
}))
});
jest.mock('nanoid', () => {
return {
nanoid: () => {}

View File

@@ -69,13 +69,22 @@ const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
min-height: 0; /* Important for proper flex behavior */
position: relative;
}
.col-separator {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: ${(props) => props.theme.console.border};
pointer-events: none;
z-index: 2;
}
.requests-header {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 4px 16px;
padding: 0;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 10px;
@@ -83,6 +92,39 @@ const StyledWrapper = styled.div`
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
.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;
}
&:hover {
color: ${(props) => props.theme.console.messageColor};
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
}
}
.requests-list {
@@ -94,9 +136,7 @@ const StyledWrapper = styled.div`
.request-row {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 2px 16px;
padding: 0;
cursor: pointer;
transition: background-color 0.1s ease;
font-size: ${(props) => props.theme.font.size.sm};
@@ -107,12 +147,19 @@ const StyledWrapper = styled.div`
}
&.selected {
padding-left: 13px;
background: ${(props) => props.theme.console.logHoverBg};
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
}
}
.request-method {
padding: 2px 8px 2px 16px;
}
.request-status {
padding: 2px 8px;
}
.method-badge {
display: inline-flex;
align-items: center;
@@ -128,6 +175,7 @@ const StyledWrapper = styled.div`
}
.request-domain {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -135,6 +183,7 @@ const StyledWrapper = styled.div`
}
.request-path {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -143,19 +192,26 @@ const StyledWrapper = styled.div`
}
.request-time {
padding: 2px 8px;
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};
}
.request-duration {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
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;
}
.text-right {
text-align: right;
}
.request-size {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};

View File

@@ -1,12 +1,26 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconNetwork
IconNetwork,
IconArrowUp,
IconArrowDown
} from '@tabler/icons';
import {
setSelectedRequest
} from 'providers/ReduxStore/slices/logs';
import StyledWrapper from './StyledWrapper';
import { getGridTemplate, getSeparatorPositions, 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: 'size', label: 'Size', width: 80, align: 'right' }
];
const MethodBadge = ({ method }) => {
const methodLower = method?.toLowerCase() || 'get';
@@ -28,7 +42,7 @@ const StatusBadge = ({ status, statusCode }) => {
);
};
const RequestRow = ({ request, isSelected, onClick }) => {
const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
const { data } = request;
const { request: req, response: res, timestamp } = data;
@@ -82,6 +96,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
<div
className={`request-row ${isSelected ? 'selected' : ''}`}
onClick={onClick}
style={{ gridTemplateColumns }}
data-testid="network-request-row"
>
<div className="request-method">
<MethodBadge method={req?.method} />
@@ -116,6 +133,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
const NetworkTab = () => {
const dispatch = useDispatch();
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
@@ -150,6 +170,21 @@ const NetworkTab = () => {
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 };
});
};
const sortedRequests = useMemo(
() => sortRequests(filteredRequests, sortConfig.key, sortConfig.direction),
[filteredRequests, sortConfig]
);
return (
<StyledWrapper>
<div className="network-content">
@@ -161,26 +196,45 @@ const NetworkTab = () => {
</div>
) : (
<div className="requests-container">
<div className="requests-header">
<div>Method</div>
<div>Status</div>
<div>Domain</div>
<div>Path</div>
<div>Time</div>
<div className="text-right">Duration</div>
<div className="text-right">Size</div>
<div className="requests-header" style={{ gridTemplateColumns }}>
{COLUMNS.map((col) => (
<div
key={col.key}
className={`header-cell${col.align === 'right' ? ' text-right' : ''}`}
onClick={() => handleHeaderClick(col.key)}
data-testid={`network-header-${col.key}`}
>
<span title={col.label}>{col.label}</span>
{sortConfig.key === col.key && (
sortConfig.direction === 'asc'
? <IconArrowUp size={14} strokeWidth={2} data-testid="sort-icon-asc" />
: <IconArrowDown size={14} strokeWidth={2} data-testid="sort-icon-desc" />
)}
</div>
))}
</div>
<div className="requests-list">
{filteredRequests.map((request, index) => (
{sortedRequests.map((request, index) => (
<RequestRow
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
request={request}
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
)}
</div>
)}
</div>

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { ThemeProvider } from 'providers/Theme';
import NetworkTab from './index';
const makeRequest = (overrides = {}) => ({
type: 'request',
timestamp: overrides.timestamp ?? 1000,
collectionUid: overrides.collectionUid ?? 'col-1',
itemUid: overrides.itemUid ?? 'item-1',
collectionName: 'Test Collection',
data: {
request: {
method: overrides.method ?? 'GET',
url: overrides.url ?? 'https://example.com/api/users'
},
response: {
status: overrides.status ?? 200,
statusCode: overrides.statusCode ?? 200,
// Use 'in' check so callers can explicitly pass undefined to test missing-value behaviour
...('duration' in overrides ? { duration: overrides.duration } : { duration: 100 }),
...('size' in overrides ? { size: overrides.size } : { size: 512 })
},
timestamp: overrides.timestamp ?? 1000
}
});
const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
const renderNetworkTab = (requests = []) => {
const store = configureStore({
reducer: {
collections: (state = {
collections: [{
uid: 'col-1',
name: 'Test Collection',
timeline: requests
}]
}) => state,
logs: (state = {
networkFilters: ALL_FILTERS,
selectedRequest: null
}) => state
}
});
return render(
<Provider store={store}>
<ThemeProvider>
<NetworkTab />
</ThemeProvider>
</Provider>
);
};
describe('sort state cycle', () => {
const requests = [
makeRequest({ itemUid: 'a', method: 'GET' }),
makeRequest({ itemUid: 'b', method: 'POST' })
];
it('shows no sort icon by default', () => {
renderNetworkTab(requests);
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('first click on a column shows ascending icon', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('second click on same column shows descending icon', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
});
it('third click on same column clears sort', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('clicking a different column resets to ascending on the new column', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method')); // now desc
fireEvent.click(screen.getByTestId('network-header-status')); // switch column
// Should show asc on status, not desc
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('sort icon only appears on the active column', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-duration'));
// Only one icon total
expect(screen.getAllByTestId('sort-icon-asc')).toHaveLength(1);
});
});
describe('sort results', () => {
const getRowMethods = () =>
screen.getAllByTestId('network-request-row').map((row) =>
row.querySelector('.method-badge')?.textContent
);
it('sorts by method ascending (A → Z)', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'DELETE' })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
});
it('sorts by method descending (Z → A)', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'DELETE' })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
});
it('sorts by status ascending', () => {
const requests = [
makeRequest({ itemUid: '1', statusCode: 500 }),
makeRequest({ itemUid: '2', statusCode: 200 }),
makeRequest({ itemUid: '3', statusCode: 404 })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-status'));
const rows = screen.getAllByTestId('network-request-row');
const statuses = rows.map((r) => r.querySelector('.status-badge')?.textContent);
expect(statuses).toEqual(['200', '404', '500']);
});
it('sorts mixed-case methods case-insensitively', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'post' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'delete' })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
// MethodBadge always renders uppercase; sort order should treat 'post' == 'POST'
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
});
it('preserves insertion order when sort is cleared', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'DELETE' })
];
renderNetworkTab(requests);
// Sort then clear
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
});
});

View File

@@ -0,0 +1,57 @@
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) {
case 'method': return req?.method?.toUpperCase() ?? '';
case 'status': return res?.statusCode || res?.status || 0;
case 'domain': {
try { return new URL(req?.url || '').hostname; } catch { return req?.url || ''; }
}
case 'path': {
try {
const u = new URL(req?.url || '');
return u.pathname + u.search;
} catch { return req?.url || ''; }
}
case 'time': return timestamp || 0;
case 'duration': return res?.duration || 0;
case 'size': return res?.size || 0;
default: return '';
}
};
export const sortRequests = (requests, key, direction) => {
if (!key || !direction) return requests;
return [...requests].sort((a, b) => {
const valueA = getSortValue(a, key);
const valueB = getSortValue(b, key);
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
return 0;
});
};