mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-24 13:15:40 +00:00
feat(dev-tools)/adds sorting on columns with verticle borders (#8238)
This commit is contained in:
committed by
GitHub
parent
7a24b1924d
commit
e7e6cdfa51
@@ -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: () => {}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user