feat(event-log): Improve UX of the event log for WS and SSE responses (#7300)

* change to tailwind and rac

* change selection on navigation

* resizable panel for the event view

* cleanup styled components

* fix preview mode
This commit is contained in:
James Gatz 2024-04-23 12:20:19 +02:00 committed by GitHub
parent db00cdeb88
commit d61167cf45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 97 additions and 204 deletions

View File

@ -1,8 +1,7 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { format } from 'date-fns';
import React, { FC, useRef } from 'react';
import { useMeasure } from 'react-use';
import styled from 'styled-components';
import { Cell, Column, Row, Table, TableBody, TableHeader } from 'react-aria-components';
import { CurlEvent } from '../../../main/network/curl';
import { WebSocketEvent } from '../../../main/network/websocket';
@ -19,67 +18,6 @@ interface Props {
onSelect: (event: WebSocketEvent | CurlEvent) => void;
}
const Divider = styled('div')({
height: '100%',
width: '1px',
backgroundColor: 'var(--hl-md)',
});
const AutoSize = styled.div({
flex: '1 0',
overflow: 'hidden',
});
const Scrollable = styled.div({
overflowY: 'scroll',
});
const HeadingRow = styled('div')({
flex: '0 0 30px',
display: 'flex',
width: '100%',
alignItems: 'center',
borderBottom: '1px solid var(--hl-md)',
paddingRight: 'var(--scrollbar-width)',
boxSizing: 'border-box',
});
const Row = styled('div')<{ isActive: boolean }>(({ isActive }) => ({
position: 'absolute',
top: 0,
left: 0,
height: '30px',
display: 'flex',
width: '100%',
alignItems: 'center',
borderBottom: '1px solid var(--hl-md)',
boxSizing: 'border-box',
backgroundColor: isActive ? 'var(--hl-lg)' : 'transparent',
}));
const List = styled('div')({
width: '100%',
position: 'relative',
});
const EventLog = styled('div')({
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
overflow: 'hidden',
borderTop: '1px solid var(--hl-md)',
});
const EventIconCell = styled('div')({
flex: '0 0 15px',
height: '100%',
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box',
padding: 'var(--padding-xs)',
});
function getIcon(event: WebSocketEvent | CurlEvent): SvgIconProps['icon'] {
switch (event.type) {
case 'message': {
@ -104,14 +42,6 @@ function getIcon(event: WebSocketEvent | CurlEvent): SvgIconProps['icon'] {
}
}
const EventMessageCell = styled('div')({
flex: '1 0',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: 'var(--padding-xs)',
});
const getMessage = (event: WebSocketEvent | CurlEvent): string => {
switch (event.type) {
case 'message': {
@ -135,13 +65,8 @@ const getMessage = (event: WebSocketEvent | CurlEvent): string => {
}
};
const EventTimestampCell = styled('div')({
flex: '0 0 80px',
padding: 'var(--padding-xs)',
});
export const EventLogView: FC<Props> = ({ events, onSelect, selectionId }) => {
const parentRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLTableSectionElement>(null);
const virtualizer = useVirtualizer({
getScrollElement: () => parentRef.current,
count: events.length,
@ -150,55 +75,63 @@ export const EventLogView: FC<Props> = ({ events, onSelect, selectionId }) => {
getItemKey: index => events[index]._id,
});
const [autoSizeRef, { height }] = useMeasure<HTMLDivElement>();
return (
<EventLog>
<HeadingRow>
<EventIconCell>
<div style={{ width: '13px' }} />
</EventIconCell>
<Divider />
<EventMessageCell>Data</EventMessageCell>
<Divider />
<EventTimestampCell>Time</EventTimestampCell>
</HeadingRow>
<AutoSize ref={autoSizeRef}>
<Scrollable style={{ height }} ref={parentRef}>
<List
style={{
height: `${virtualizer.getTotalSize()}px`,
<>
<div className='w-full flex-1 overflow-hidden border border-solid border-[--hl-sm] select-none overflow-y-auto max-h-96'>
<Table
selectionMode='single'
selectedKeys={selectionId ? [selectionId] : []}
selectionBehavior='replace'
onSelectionChange={keys => {
if (keys !== 'all') {
const key = keys.values().next().value;
const event = events.find(e => e._id === key);
if (event) {
onSelect(event);
}
}
}}
aria-label='Modified objects'
className="border-separate border-spacing-0 w-full"
>
{virtualizer.getVirtualItems().map(item => {
<TableHeader className='sticky top-0 z-10 backdrop-blur backdrop-filter bg-[--hl-xs]'>
<Column isRowHeader className="p-3 text-left text-xs font-semibold focus:outline-none">
<span />
</Column>
<Column className="p-3 text-left text-xs font-semibold focus:outline-none">
Data
</Column>
<Column className="p-3 text-left text-xs font-semibold focus:outline-none">
Time
</Column>
</TableHeader>
<TableBody
style={{ height: virtualizer.getTotalSize() }}
ref={parentRef}
className="divide divide-[--hl-sm] divide-solid"
items={virtualizer.getVirtualItems()}
>
{item => {
const event = events[item.index];
return (
<Row
key={item.key}
onClick={() => onSelect(event)}
isActive={event._id === selectionId}
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`,
}}
>
<EventIconCell>
<Row className="group focus:outline-none focus-within:bg-[--hl-sm] transition-colors">
<Cell className="p-2 whitespace-nowrap text-sm font-medium border-b border-solid border-[--hl-sm] group-last-of-type:border-none focus:outline-none">
<SvgIcon icon={getIcon(event)} />
</EventIconCell>
<Divider />
<EventMessageCell>{getMessage(event)}</EventMessageCell>
<Divider />
<EventTimestampCell>
</Cell>
<Cell className="whitespace-nowrap text-sm font-medium border-b border-solid border-[--hl-sm] group-last-of-type:border-none focus:outline-none">
{getMessage(event)}
</Cell>
<Cell className="whitespace-nowrap text-sm font-medium border-b border-solid border-[--hl-sm] group-last-of-type:border-none focus:outline-none">
<Timestamp time={event.timestamp} />
</EventTimestampCell>
</Cell>
</Row>
);
})}
</List>
</Scrollable>
</AutoSize>
</EventLog>
}}
</TableBody>
</Table>
</div>
</>
);
};

View File

@ -3,10 +3,10 @@ import React, { FC, useCallback } from 'react';
import { useParams, useRouteLoaderData } from 'react-router-dom';
import styled from 'styled-components';
import { PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW, PREVIEW_MODE_SOURCE, PreviewMode } from '../../../common/constants';
import { PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW, PREVIEW_MODE_SOURCE } from '../../../common/constants';
import { CurlEvent, CurlMessageEvent } from '../../../main/network/curl';
import { WebSocketEvent, WebSocketMessageEvent } from '../../../main/network/websocket';
import { requestMeta } from '../../../models';
import { useRequestMetaPatcher } from '../../hooks/use-request';
import { RequestLoaderData } from '../../routes/request';
import { CodeEditor } from '../codemirror/code-editor';
import { showError } from '../modals';
@ -78,9 +78,7 @@ export const MessageEventView: FC<Props<CurlMessageEvent | WebSocketMessageEvent
window.clipboard.writeText(raw);
}, [raw]);
const setPreviewMode = async (previewMode: PreviewMode) => {
return requestMeta.updateOrCreateByParentId(requestId, { previewMode });
};
const patchRequestMeta = useRequestMetaPatcher();
let pretty = raw;
try {
@ -98,7 +96,7 @@ export const MessageEventView: FC<Props<CurlMessageEvent | WebSocketMessageEvent
download={handleDownloadResponseBody}
copyToClipboard={handleCopyResponseToClipboard}
previewMode={previewMode}
setPreviewMode={setPreviewMode}
setPreviewMode={previewMode => patchRequestMeta(requestId, { previewMode })}
/>
</PreviewPaneButtons>
<PreviewPaneContents>

View File

@ -1,5 +1,7 @@
import fs from 'fs';
import React, { FC, useEffect, useRef, useState } from 'react';
import React, { FC, useEffect, useState } from 'react';
import { Button, Input, SearchField } from 'react-aria-components';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useRouteLoaderData } from 'react-router-dom';
import styled from 'styled-components';
@ -14,6 +16,7 @@ import { RequestLoaderData, WebSocketRequestLoaderData } from '../../routes/requ
import { PanelContainer, TabItem, Tabs } from '../base/tabs';
import { ResponseHistoryDropdown } from '../dropdowns/response-history-dropdown';
import { ErrorBoundary } from '../error-boundary';
import { Icon } from '../icon';
import { Pane, PaneHeader as OriginalPaneHeader } from '../panes/pane';
import { PlaceholderResponsePane } from '../panes/placeholder-response-pane';
import { SvgIcon } from '../svg-icon';
@ -31,52 +34,6 @@ const PaneHeader = styled(OriginalPaneHeader)({
'&&': { justifyContent: 'unset' },
});
const EventLogTableWrapper = styled.div({
width: '100%',
flex: 1,
overflow: 'hidden',
boxSizing: 'border-box',
});
const EventViewWrapper = styled.div({
flex: 1,
borderTop: '1px solid var(--hl-md)',
height: '100%',
});
const EventSearchFormControl = styled.div({
outline: 'none',
width: '100%',
boxSizing: 'border-box',
position: 'relative',
display: 'flex',
border: '1px solid var(--hl-md)',
borderRadius: 'var(--radius-md)',
});
const EventSearchInput = styled.input({
paddingRight: '2em',
padding: 'var(--padding-sm)',
backgroundColor: 'var(--hl-xxs)',
width: '100%',
display: 'block',
boxSizing: 'border-box',
// Remove the default search input cancel button
'::-webkit-search-cancel-button': {
display: 'none',
},
':focus': {
backgroundColor: 'transparent',
borderColor: 'var(--hl-lg)',
},
});
const PaddedButton = styled('button')({
padding: 'var(--padding-sm)',
});
export const RealtimeResponsePane: FC<{ requestId: string }> = () => {
const { activeResponse } = useRouteLoaderData('request/:requestId') as RequestLoaderData | WebSocketRequestLoaderData;
@ -96,7 +53,6 @@ const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response }>
}) => {
const [selectedEvent, setSelectedEvent] = useState<CurlEvent | WebSocketEvent | null>(null);
const [timeline, setTimeline] = useState<ResponseTimelineEntry[]>([]);
const searchInputRef = useRef<HTMLInputElement>(null);
const [clearEventsBefore, setClearEventsBefore] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [eventType, setEventType] = useState<CurlEvent['type']>();
@ -173,10 +129,10 @@ const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response }>
</PaneHeader>
<Tabs aria-label="Curl response pane tabs">
<TabItem key="events" title="Events">
<div className='h-full w-full grid grid-rows-[repeat(auto-fit,minmax(0,1fr))]'>
<PanelGroup direction='vertical' className='h-full w-full grid grid-rows-[repeat(auto-fit,minmax(0,1fr))]'>
{response.error ? <ResponseErrorViewer url={response.url} error={response.error} />
: <>
<EventLogTableWrapper>
<Panel minSize={10} defaultSize={50} className="w-full flex flex-col overflow-hidden box-border flex-1">
<div
style={{
display: 'flex',
@ -193,35 +149,36 @@ const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response }>
<option value="error">Error</option>
</select>
<EventSearchFormControl>
<EventSearchInput
ref={searchInputRef}
type="search"
placeholder="Search"
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
/>
{searchQuery && (
<PaddedButton
className="form-control__right"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
<SearchField
aria-label="Events filter"
className="group relative flex-1 w-full"
defaultValue={searchQuery}
onChange={query => {
setSearchQuery(query);
}}
>
<i className="fa fa-times-circle" />
</PaddedButton>
)}
</EventSearchFormControl>
<PaddedButton
onClick={() => {
<Input
placeholder="Search"
className="py-1 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors"
/>
<div className="flex items-center px-2 absolute right-0 top-0 h-full">
<Button className="flex group-data-[empty]:hidden items-center justify-center aspect-square w-5 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
<Icon icon="close" />
</Button>
</div>
</SearchField>
<Button
aria-label="Create in collection"
className="flex items-center justify-center h-full aspect-square aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() => {
const lastEvent = events[0];
setClearEventsBefore(lastEvent.timestamp);
}}
>
<SvgIcon icon='prohibited' />
</PaddedButton>
</Button>
</div>
{Boolean(events?.length) && (
<EventLogView
events={events}
@ -229,17 +186,22 @@ const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response }>
selectionId={selectedEvent?._id}
/>
)}
</EventLogTableWrapper>
</Panel>
{selectedEvent && (
<EventViewWrapper>
<>
<PanelResizeHandle className={'w-full h-[1px] bg-[--hl-md]'} />
<Panel minSize={10} defaultSize={50}>
<div className="flex-1 border-t border-[var(--hl-md)] h-full">
<EventView
key={selectedEvent._id}
event={selectedEvent}
/>
</EventViewWrapper>
</div>
</Panel>
</>
)}
</>}
</div>
</PanelGroup>
</TabItem>
<TabItem
key="headers"