mirror of
https://github.com/Kong/insomnia
synced 2024-11-08 06:39:48 +00:00
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:
parent
db00cdeb88
commit
d61167cf45
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user