From e7e6cdfa5151f2a7466a1d1c176e203b7a942e36 Mon Sep 17 00:00:00 2001 From: sachin-thakur-bruno Date: Fri, 12 Jun 2026 17:58:22 +0530 Subject: [PATCH] feat(dev-tools)/adds sorting on columns with verticle borders (#8238) --- packages/bruno-app/jest.setup.js | 16 ++ .../Console/NetworkTab/StyledWrapper.js | 72 ++++++- .../Devtools/Console/NetworkTab/index.js | 78 ++++++-- .../Devtools/Console/NetworkTab/index.spec.js | 179 ++++++++++++++++++ .../Devtools/Console/NetworkTab/utils.js | 57 ++++++ 5 files changed, 382 insertions(+), 20 deletions(-) create mode 100644 packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js create mode 100644 packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js diff --git a/packages/bruno-app/jest.setup.js b/packages/bruno-app/jest.setup.js index 1dbb39d85..5863193d4 100644 --- a/packages/bruno-app/jest.setup.js +++ b/packages/bruno-app/jest.setup.js @@ -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: () => {} diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js index de1aff766..8142d6520 100644 --- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js @@ -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}; diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js index e424b690c..4fae5745c 100644 --- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js @@ -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 }) => {
@@ -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 (
@@ -161,26 +196,45 @@ const NetworkTab = () => {
) : (
-
-
Method
-
Status
-
Domain
-
Path
-
Time
-
Duration
-
Size
+
+ {COLUMNS.map((col) => ( +
handleHeaderClick(col.key)} + data-testid={`network-header-${col.key}`} + > + {col.label} + {sortConfig.key === col.key && ( + sortConfig.direction === 'asc' + ? + : + )} +
+ ))}
- {filteredRequests.map((request, index) => ( + {sortedRequests.map((request, index) => ( handleRequestClick(request)} + gridTemplateColumns={gridTemplateColumns} /> ))}
+ + {separatorPositions.map((pos, i) => + pos ? ( +
+ ) : null + )}
)}
diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js new file mode 100644 index 000000000..5ae619b94 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js @@ -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( + + + + + + ); +}; + +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']); + }); +}); diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js new file mode 100644 index 000000000..507fced80 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js @@ -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; + }); +};