Convert plugin UI to Sandy
Summary: Changelog: Updated Network plugin to Sandy UI, including several UI improvements Converted UI to Sandy, and some minor code cleanups Moved all mock related logic to its own dir Fixes https://github.com/facebook/flipper/issues/2267 Reviewed By: passy Differential Revision: D27966606 fbshipit-source-id: a64e20276d7f0966ce7a95b22557762a32c184cd
This commit is contained in:
committed by
Facebook GitHub Bot
parent
84d65b1a77
commit
fc4a08eb55
46
desktop/plugins/public/network/KeyValueTable.tsx
Normal file
46
desktop/plugins/public/network/KeyValueTable.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import {DataTable, DataTableColumn} from 'flipper-plugin';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export type KeyValueItem = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const columns: DataTableColumn<KeyValueItem>[] = [
|
||||
{
|
||||
key: 'key',
|
||||
width: 160,
|
||||
title: 'Key',
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
title: 'Value',
|
||||
wrap: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function KeyValueTable({items}: {items: KeyValueItem[]}) {
|
||||
const handleCopyRows = useCallback((rows: KeyValueItem[]) => {
|
||||
return rows.map(({key, value}) => `${key}: ${value}`).join('\n');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DataTable<KeyValueItem>
|
||||
columns={columns}
|
||||
records={items}
|
||||
enableSearchbar={false}
|
||||
scrollable={false}
|
||||
onCopyRows={handleCopyRows}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
ManagedTable,
|
||||
Text,
|
||||
Glyph,
|
||||
styled,
|
||||
colors,
|
||||
Panel,
|
||||
} from 'flipper';
|
||||
import React, {useContext, useState, useMemo, useEffect} from 'react';
|
||||
import {Route, Requests} from './types';
|
||||
import {MockResponseDetails} from './MockResponseDetails';
|
||||
import {NetworkRouteContext} from './index';
|
||||
import {RequestId} from './types';
|
||||
import {message, Checkbox, Modal, Tooltip} from 'antd';
|
||||
import {NUX, Layout} from 'flipper-plugin';
|
||||
|
||||
type Props = {
|
||||
routes: {[id: string]: Route};
|
||||
highlightedRows: Set<string> | null | undefined;
|
||||
requests: Requests;
|
||||
};
|
||||
|
||||
const ColumnSizes = {route: 'flex'};
|
||||
|
||||
const Columns = {route: {value: 'Route', resizable: false}};
|
||||
|
||||
const TextEllipsis = styled(Text)({
|
||||
overflowX: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
lineHeight: '18px',
|
||||
paddingTop: 4,
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginTop: 5,
|
||||
marginRight: 8,
|
||||
});
|
||||
|
||||
// return ids that have the same pair of requestUrl and method; this will return only the duplicate
|
||||
function _duplicateIds(routes: {[id: string]: Route}): Array<RequestId> {
|
||||
const idSet: {[id: string]: {[method: string]: boolean}} = {};
|
||||
return Object.entries(routes).reduce((acc: Array<RequestId>, [id, route]) => {
|
||||
if (idSet.hasOwnProperty(route.requestUrl)) {
|
||||
if (idSet[route.requestUrl].hasOwnProperty(route.requestMethod)) {
|
||||
return acc.concat(id);
|
||||
}
|
||||
idSet[route.requestUrl] = {
|
||||
...idSet[route.requestUrl],
|
||||
[route.requestMethod]: true,
|
||||
};
|
||||
return acc;
|
||||
} else {
|
||||
idSet[route.requestUrl] = {[route.requestMethod]: true};
|
||||
return acc;
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
function _buildRows(
|
||||
routes: {[id: string]: Route},
|
||||
duplicatedIds: Array<string>,
|
||||
handleRemoveId: (id: string) => void,
|
||||
handleEnableId: (id: string) => void,
|
||||
) {
|
||||
return Object.entries(routes).map(([id, route]) => ({
|
||||
columns: {
|
||||
route: {
|
||||
value: (
|
||||
<RouteRow
|
||||
key={id}
|
||||
text={route.requestUrl}
|
||||
showWarning={duplicatedIds.includes(id)}
|
||||
handleRemoveId={() => handleRemoveId(id)}
|
||||
handleEnableId={() => handleEnableId(id)}
|
||||
enabled={route.enabled}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
key: id,
|
||||
}));
|
||||
}
|
||||
|
||||
function RouteRow(props: {
|
||||
text: string;
|
||||
showWarning: boolean;
|
||||
handleRemoveId: () => void;
|
||||
handleEnableId: () => void;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const tip = props.enabled
|
||||
? 'Un-check to disable mock route'
|
||||
: 'Check to enable mock route';
|
||||
return (
|
||||
<Layout.Horizontal gap>
|
||||
<Tooltip title={tip} mouseEnterDelay={1.1}>
|
||||
<Checkbox
|
||||
onClick={props.handleEnableId}
|
||||
checked={props.enabled}></Checkbox>
|
||||
</Tooltip>
|
||||
<Tooltip title="Click to delete mock route" mouseEnterDelay={1.1}>
|
||||
<Layout.Horizontal onClick={props.handleRemoveId}>
|
||||
<Icon name="cross-circle" color={colors.red} />
|
||||
</Layout.Horizontal>
|
||||
</Tooltip>
|
||||
{props.showWarning && (
|
||||
<Icon name="caution-triangle" color={colors.yellow} />
|
||||
)}
|
||||
{props.text.length === 0 ? (
|
||||
<TextEllipsis style={{color: colors.blackAlpha50}}>
|
||||
untitled
|
||||
</TextEllipsis>
|
||||
) : (
|
||||
<TextEllipsis>{props.text}</TextEllipsis>
|
||||
)}
|
||||
</Layout.Horizontal>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedMockResponseRightPanel(props: {
|
||||
id: string;
|
||||
route: Route;
|
||||
isDuplicated: boolean;
|
||||
}) {
|
||||
const {id, route, isDuplicated} = props;
|
||||
return (
|
||||
<Panel
|
||||
grow={true}
|
||||
collapsable={false}
|
||||
floating={false}
|
||||
heading={'Route Info'}>
|
||||
<MockResponseDetails
|
||||
key={id}
|
||||
id={id}
|
||||
route={route}
|
||||
isDuplicated={isDuplicated}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManageMockResponsePanel(props: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
const [selectedId, setSelectedId] = useState<RequestId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId((selectedId) => {
|
||||
const keys = Object.keys(props.routes);
|
||||
let returnValue: string | null = null;
|
||||
// selectId is null when there are no rows or it is the first time rows are shown
|
||||
if (selectedId === null) {
|
||||
if (keys.length === 0) {
|
||||
// there are no rows
|
||||
returnValue = null;
|
||||
} else {
|
||||
// first time rows are shown
|
||||
returnValue = keys[0];
|
||||
}
|
||||
} else {
|
||||
if (keys.includes(selectedId)) {
|
||||
returnValue = selectedId;
|
||||
} else {
|
||||
// selectedId row value not in routes so default to first line
|
||||
returnValue = keys[0];
|
||||
}
|
||||
}
|
||||
return returnValue;
|
||||
});
|
||||
}, [props.routes]);
|
||||
const duplicatedIds = useMemo(() => _duplicateIds(props.routes), [
|
||||
props.routes,
|
||||
]);
|
||||
|
||||
function getSelectedIds(): Set<string> {
|
||||
const newSet = new Set<string>();
|
||||
newSet.add(selectedId ?? '');
|
||||
return newSet;
|
||||
}
|
||||
|
||||
function getPreviousId(id: string): string | null {
|
||||
const keys = Object.keys(props.routes);
|
||||
const currentIndex = keys.indexOf(id);
|
||||
if (currentIndex == 0) {
|
||||
return null;
|
||||
} else {
|
||||
return keys[currentIndex - 1];
|
||||
}
|
||||
}
|
||||
|
||||
function getNextId(id: string): string | null {
|
||||
const keys = Object.keys(props.routes);
|
||||
const currentIndex = keys.indexOf(id);
|
||||
if (currentIndex >= keys.length - 1) {
|
||||
return getPreviousId(id);
|
||||
} else {
|
||||
return keys[currentIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Container style={{height: 550}}>
|
||||
<Layout.Left>
|
||||
<Layout.Container width={450} pad={10} gap={5}>
|
||||
<Layout.Horizontal gap>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newId = networkRouteManager.addRoute();
|
||||
setSelectedId(newId);
|
||||
}}>
|
||||
Add Route
|
||||
</Button>
|
||||
<NUX
|
||||
title="It is now possible to select calls from the network call list and convert them into mock routes."
|
||||
placement="bottom">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
!props.highlightedRows ||
|
||||
props.highlightedRows.size == 0
|
||||
) {
|
||||
message.info('No network calls have been selected');
|
||||
return;
|
||||
}
|
||||
networkRouteManager.copyHighlightedCalls(
|
||||
props.highlightedRows as Set<string>,
|
||||
props.requests,
|
||||
);
|
||||
}}>
|
||||
Copy Highlighted Calls
|
||||
</Button>
|
||||
</NUX>
|
||||
</Layout.Horizontal>
|
||||
<Panel
|
||||
padded={false}
|
||||
grow={true}
|
||||
collapsable={false}
|
||||
floating={false}
|
||||
heading={'Routes'}>
|
||||
<ManagedTable
|
||||
hideHeader={true}
|
||||
multiline={false}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
rows={_buildRows(
|
||||
props.routes,
|
||||
duplicatedIds,
|
||||
(id) => {
|
||||
Modal.confirm({
|
||||
title: 'Are you sure you want to delete this item?',
|
||||
icon: '',
|
||||
onOk() {
|
||||
const nextId = getNextId(id);
|
||||
networkRouteManager.removeRoute(id);
|
||||
setSelectedId(nextId);
|
||||
},
|
||||
onCancel() {},
|
||||
});
|
||||
},
|
||||
(id) => {
|
||||
networkRouteManager.enableRoute(id);
|
||||
},
|
||||
)}
|
||||
stickyBottom={true}
|
||||
autoHeight={false}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
onRowHighlighted={(selectedIds) => {
|
||||
const newSelectedId =
|
||||
selectedIds.length === 1 ? selectedIds[0] : null;
|
||||
setSelectedId(newSelectedId);
|
||||
}}
|
||||
highlightedRows={getSelectedIds()}
|
||||
/>
|
||||
</Panel>
|
||||
</Layout.Container>
|
||||
<Layout.Container>
|
||||
{selectedId && props.routes.hasOwnProperty(selectedId) && (
|
||||
<ManagedMockResponseRightPanel
|
||||
id={selectedId}
|
||||
route={props.routes[selectedId]}
|
||||
isDuplicated={duplicatedIds.includes(selectedId)}
|
||||
/>
|
||||
)}
|
||||
</Layout.Container>
|
||||
</Layout.Left>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
FlexRow,
|
||||
FlexColumn,
|
||||
Layout,
|
||||
Button,
|
||||
Input,
|
||||
Text,
|
||||
Tabs,
|
||||
Tab,
|
||||
Glyph,
|
||||
ManagedTable,
|
||||
Select,
|
||||
styled,
|
||||
colors,
|
||||
produce,
|
||||
} from 'flipper';
|
||||
import React, {useContext, useState} from 'react';
|
||||
import {NetworkRouteContext, NetworkRouteManager} from './index';
|
||||
import {RequestId, Route} from './types';
|
||||
|
||||
type Props = {
|
||||
id: RequestId;
|
||||
route: Route;
|
||||
isDuplicated: boolean;
|
||||
};
|
||||
|
||||
const StyledSelectContainer = styled(FlexRow)({
|
||||
paddingLeft: 6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 24,
|
||||
height: '100%',
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const StyledSelect = styled(Select)({
|
||||
height: '100%',
|
||||
maxWidth: 400,
|
||||
});
|
||||
|
||||
const StyledText = styled(Text)({
|
||||
marginLeft: 6,
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
const textAreaStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
height: 400,
|
||||
fontSize: 15,
|
||||
color: '#333',
|
||||
padding: 10,
|
||||
resize: 'none',
|
||||
fontFamily:
|
||||
'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
||||
display: 'inline-block',
|
||||
lineHeight: 1.5,
|
||||
border: '1px solid #dcdee2',
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#fff',
|
||||
cursor: 'text',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
};
|
||||
|
||||
const StyledInput = styled(Input)({
|
||||
width: '100%',
|
||||
height: 20,
|
||||
marginLeft: 8,
|
||||
flexGrow: 5,
|
||||
});
|
||||
|
||||
const HeaderStyledInput = styled(Input)({
|
||||
width: '100%',
|
||||
height: 20,
|
||||
marginTop: 6,
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
const HeaderGlyph = styled(Glyph)({
|
||||
marginTop: 6,
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'flex-start',
|
||||
alignContent: 'flex-start',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const Warning = styled(FlexRow)({
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
const HeadersColumnSizes = {
|
||||
close: '4%',
|
||||
warning: '4%',
|
||||
name: '35%',
|
||||
value: 'flex',
|
||||
};
|
||||
|
||||
const HeadersColumns = {
|
||||
close: {
|
||||
value: '',
|
||||
resizable: false,
|
||||
},
|
||||
warning: {
|
||||
value: '',
|
||||
resizable: false,
|
||||
},
|
||||
name: {
|
||||
value: 'Name',
|
||||
resizable: false,
|
||||
},
|
||||
value: {
|
||||
value: 'Value',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
|
||||
const selectedHighlight = {backgroundColor: colors.highlight};
|
||||
|
||||
function HeaderInput(props: {
|
||||
initialValue: string;
|
||||
isSelected: boolean;
|
||||
onUpdate: (newValue: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(props.initialValue);
|
||||
return (
|
||||
<HeaderStyledInput
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={value}
|
||||
style={props.isSelected ? selectedHighlight : undefined}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => props.onUpdate(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function _buildMockResponseHeaderRows(
|
||||
routeId: string,
|
||||
route: Route,
|
||||
selectedHeaderId: string | null,
|
||||
networkRouteManager: NetworkRouteManager,
|
||||
) {
|
||||
return Object.entries(route.responseHeaders).map(([id, header]) => {
|
||||
const selected = selectedHeaderId === id;
|
||||
return {
|
||||
columns: {
|
||||
name: {
|
||||
value: (
|
||||
<HeaderInput
|
||||
initialValue={header.key}
|
||||
isSelected={selected}
|
||||
onUpdate={(newValue: string) => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
draftHeaders[id].key = newValue;
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
value: {
|
||||
value: (
|
||||
<HeaderInput
|
||||
initialValue={header.value}
|
||||
isSelected={selected}
|
||||
onUpdate={(newValue: string) => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
draftHeaders[id].value = newValue;
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
close: {
|
||||
value: (
|
||||
<Layout.Container
|
||||
onClick={() => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
delete draftHeaders[id];
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}>
|
||||
<HeaderGlyph name="cross-circle" color={colors.red} />
|
||||
</Layout.Container>
|
||||
),
|
||||
},
|
||||
},
|
||||
key: id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MockResponseDetails({id, route, isDuplicated}: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
const [activeTab, setActiveTab] = useState<string>('data');
|
||||
const [selectedHeaderIds, setSelectedHeaderIds] = useState<Array<RequestId>>(
|
||||
[],
|
||||
);
|
||||
const [nextHeaderId, setNextHeaderId] = useState(0);
|
||||
|
||||
const {requestUrl, requestMethod, responseData, responseStatus} = route;
|
||||
|
||||
let formattedResponse = '';
|
||||
try {
|
||||
formattedResponse = JSON.stringify(JSON.parse(responseData), null, 2);
|
||||
} catch (e) {
|
||||
formattedResponse = responseData;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<FlexRow style={{width: '100%'}}>
|
||||
<StyledSelectContainer>
|
||||
<StyledSelect
|
||||
grow={true}
|
||||
selected={requestMethod}
|
||||
options={{
|
||||
GET: 'GET',
|
||||
POST: 'POST',
|
||||
PATCH: 'PATCH',
|
||||
HEAD: 'HEAD',
|
||||
PUT: 'PUT',
|
||||
DELETE: 'DELETE',
|
||||
TRACE: 'TRACE',
|
||||
OPTIONS: 'OPTIONS',
|
||||
CONNECT: 'CONNECT',
|
||||
}}
|
||||
onChange={(text: string) =>
|
||||
networkRouteManager.modifyRoute(id, {requestMethod: text})
|
||||
}
|
||||
/>
|
||||
</StyledSelectContainer>
|
||||
<StyledInput
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
value={requestUrl}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
requestUrl: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FlexRow>
|
||||
<FlexRow style={{width: '20%'}}>
|
||||
<StyledInput
|
||||
type="text"
|
||||
placeholder="STATUS"
|
||||
value={responseStatus}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseStatus: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FlexRow>
|
||||
{isDuplicated && (
|
||||
<Warning>
|
||||
<Glyph name="caution-triangle" color={colors.yellow} />
|
||||
<Text style={{marginLeft: 5}}>
|
||||
Route is duplicated (Same URL and Method)
|
||||
</Text>
|
||||
</Warning>
|
||||
)}
|
||||
<StyledText />
|
||||
<Tabs
|
||||
active={activeTab}
|
||||
onActive={(newActiveTab) => {
|
||||
if (newActiveTab != null) {
|
||||
setActiveTab(newActiveTab);
|
||||
}
|
||||
}}>
|
||||
<Tab key={'data'} label={'Data'}>
|
||||
<textarea
|
||||
style={textAreaStyle}
|
||||
wrap="soft"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={formattedResponse}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseData: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key={'headers'} label={'Headers'}>
|
||||
<Layout.Container style={{width: '100%'}}>
|
||||
<Layout.Horizontal>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newHeaders = {
|
||||
...route.responseHeaders,
|
||||
[nextHeaderId.toString()]: {key: '', value: ''},
|
||||
};
|
||||
setNextHeaderId(nextHeaderId + 1);
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
compact
|
||||
padded
|
||||
style={{marginBottom: 10}}>
|
||||
Add Header
|
||||
</Button>
|
||||
</Layout.Horizontal>
|
||||
<Layout.ScrollContainer>
|
||||
<ManagedTable
|
||||
hideHeader={true}
|
||||
multiline={true}
|
||||
columnSizes={HeadersColumnSizes}
|
||||
columns={HeadersColumns}
|
||||
rows={_buildMockResponseHeaderRows(
|
||||
id,
|
||||
route,
|
||||
selectedHeaderIds.length === 1 ? selectedHeaderIds[0] : null,
|
||||
networkRouteManager,
|
||||
)}
|
||||
stickyBottom={true}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
onRowHighlighted={setSelectedHeaderIds}
|
||||
highlightedRows={new Set(selectedHeaderIds)}
|
||||
/>
|
||||
</Layout.ScrollContainer>
|
||||
</Layout.Container>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Button, styled, Layout, Spacer} from 'flipper';
|
||||
|
||||
import {ManageMockResponsePanel} from './ManageMockResponsePanel';
|
||||
import {Route, Requests} from './types';
|
||||
import React from 'react';
|
||||
|
||||
import {NetworkRouteContext} from './index';
|
||||
import {useContext} from 'react';
|
||||
|
||||
type Props = {
|
||||
routes: {[id: string]: Route};
|
||||
onHide: () => void;
|
||||
highlightedRows: Set<string> | null | undefined;
|
||||
requests: Requests;
|
||||
};
|
||||
|
||||
const Title = styled('div')({
|
||||
fontWeight: 500,
|
||||
marginBottom: 10,
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
const StyledContainer = styled(Layout.Container)({
|
||||
padding: 10,
|
||||
width: 1200,
|
||||
});
|
||||
|
||||
export function MockResponseDialog(props: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
return (
|
||||
<StyledContainer pad gap width={1200}>
|
||||
<Title>Mock Network Responses</Title>
|
||||
<Layout.Container>
|
||||
<ManageMockResponsePanel
|
||||
routes={props.routes}
|
||||
highlightedRows={props.highlightedRows}
|
||||
requests={props.requests}
|
||||
/>
|
||||
</Layout.Container>
|
||||
<Layout.Horizontal gap>
|
||||
<Button
|
||||
compact
|
||||
padded
|
||||
onClick={() => {
|
||||
networkRouteManager.importRoutes();
|
||||
}}>
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
padded
|
||||
onClick={() => {
|
||||
networkRouteManager.exportRoutes();
|
||||
}}>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
padded
|
||||
onClick={() => {
|
||||
networkRouteManager.clearRoutes();
|
||||
}}>
|
||||
Clear
|
||||
</Button>
|
||||
<Spacer />
|
||||
<Button compact padded onClick={props.onHide}>
|
||||
Close
|
||||
</Button>
|
||||
</Layout.Horizontal>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@@ -7,51 +7,29 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Request, Header, Insights, RetryInsights} from './types';
|
||||
|
||||
import {
|
||||
Component,
|
||||
FlexColumn,
|
||||
ManagedTable,
|
||||
ManagedDataInspector,
|
||||
Text,
|
||||
Panel,
|
||||
Select,
|
||||
styled,
|
||||
colors,
|
||||
SmallText,
|
||||
} from 'flipper';
|
||||
import {decodeBody, getHeaderValue} from './utils';
|
||||
import {formatBytes, BodyOptions} from './index';
|
||||
import React from 'react';
|
||||
|
||||
import {Component} from 'react';
|
||||
import querystring from 'querystring';
|
||||
import xmlBeautifier from 'xml-beautifier';
|
||||
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
||||
import {Base64} from 'js-base64';
|
||||
|
||||
const WrappingText = styled(Text)({
|
||||
wordWrap: 'break-word',
|
||||
width: '100%',
|
||||
lineHeight: '125%',
|
||||
padding: '3px 0',
|
||||
});
|
||||
import {
|
||||
DataInspector,
|
||||
Layout,
|
||||
Panel,
|
||||
styled,
|
||||
theme,
|
||||
CodeBlock,
|
||||
} from 'flipper-plugin';
|
||||
import {Select, Typography} from 'antd';
|
||||
|
||||
const KeyValueColumnSizes = {
|
||||
key: '30%',
|
||||
value: 'flex',
|
||||
};
|
||||
import {formatBytes, decodeBody, getHeaderValue} from './utils';
|
||||
import {Request, Header, Insights, RetryInsights} from './types';
|
||||
import {BodyOptions} from './index';
|
||||
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
||||
import {KeyValueItem, KeyValueTable} from './KeyValueTable';
|
||||
|
||||
const KeyValueColumns = {
|
||||
key: {
|
||||
value: 'Key',
|
||||
resizable: false,
|
||||
},
|
||||
value: {
|
||||
value: 'Value',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
const {Text} = Typography;
|
||||
|
||||
type RequestDetailsProps = {
|
||||
request: Request;
|
||||
@@ -59,52 +37,23 @@ type RequestDetailsProps = {
|
||||
onSelectFormat: (bodyFormat: string) => void;
|
||||
};
|
||||
export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
static Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
urlColumns = (url: URL) => {
|
||||
return [
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Full URL</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.href}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.href,
|
||||
key: 'url',
|
||||
key: 'Full URL',
|
||||
value: url.href,
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Host</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.host}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.host,
|
||||
key: 'host',
|
||||
key: 'Host',
|
||||
value: url.host,
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Path</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.pathname}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.pathname,
|
||||
key: 'path',
|
||||
key: 'Path',
|
||||
value: url.pathname,
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Query String</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.search}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.search,
|
||||
key: 'query',
|
||||
key: 'Query String',
|
||||
value: url.search,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -113,51 +62,28 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
const {request, bodyFormat, onSelectFormat} = this.props;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const formattedText = bodyFormat == BodyOptions.formatted;
|
||||
const formattedText = bodyFormat == 'formatted';
|
||||
|
||||
return (
|
||||
<RequestDetails.Container>
|
||||
<Panel
|
||||
key="request"
|
||||
heading={'Request'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={this.urlColumns(url)}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
<>
|
||||
<Panel key="request" title={'Request'}>
|
||||
<KeyValueTable items={this.urlColumns(url)} />
|
||||
</Panel>
|
||||
|
||||
{url.search ? (
|
||||
<Panel
|
||||
heading={'Request Query Parameters'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<Panel title={'Request Query Parameters'}>
|
||||
<QueryInspector queryParams={url.searchParams} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.requestHeaders.length > 0 ? (
|
||||
<Panel
|
||||
key="headers"
|
||||
heading={'Request Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<Panel key="headers" title={'Request Headers'}>
|
||||
<HeaderInspector headers={request.requestHeaders} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.requestData != null ? (
|
||||
<Panel
|
||||
key="requestData"
|
||||
heading={'Request Body'}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<Panel key="requestData" title={'Request Body'} pad>
|
||||
<RequestBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
@@ -169,21 +95,18 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
{request.responseHeaders?.length ? (
|
||||
<Panel
|
||||
key={'responseheaders'}
|
||||
heading={`Response Headers${
|
||||
title={`Response Headers${
|
||||
request.responseIsMock ? ' (Mocked)' : ''
|
||||
}`}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
}`}>
|
||||
<HeaderInspector headers={request.responseHeaders} />
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel
|
||||
key={'responsebody'}
|
||||
heading={`Response Body${
|
||||
title={`Response Body${
|
||||
request.responseIsMock ? ' (Mocked)' : ''
|
||||
}`}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
pad>
|
||||
<ResponseBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
@@ -191,64 +114,34 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
<Panel
|
||||
key="options"
|
||||
heading={'Options'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<Panel key="options" title={'Options'} collapsed pad>
|
||||
<Text>Body formatting:</Text>
|
||||
<Select
|
||||
grow
|
||||
label="Body"
|
||||
selected={bodyFormat}
|
||||
value={bodyFormat}
|
||||
onChange={onSelectFormat}
|
||||
options={BodyOptions}
|
||||
/>
|
||||
</Panel>
|
||||
{request.insights ? (
|
||||
<Panel
|
||||
key="insights"
|
||||
heading={'Insights'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<Panel key="insights" title={'Insights'} collapsed>
|
||||
<InsightsInspector insights={request.insights} />
|
||||
</Panel>
|
||||
) : null}
|
||||
</RequestDetails.Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
||||
render() {
|
||||
const {queryParams} = this.props;
|
||||
|
||||
const rows: any = [];
|
||||
queryParams.forEach((value: string, key: string) => {
|
||||
const rows: KeyValueItem[] = [];
|
||||
this.props.queryParams.forEach((value: string, key: string) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key: key,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,43 +165,15 @@ class HeaderInspector extends Component<
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const rows: any = [];
|
||||
Array.from(computedHeaders.entries())
|
||||
const rows = Array.from(computedHeaders.entries())
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
|
||||
.forEach(([key, value]) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key,
|
||||
});
|
||||
});
|
||||
|
||||
.map(([key, value]) => ({key, value}));
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
<KeyValueTable items={this.props.headers} />
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyContainer = styled.div({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
});
|
||||
|
||||
type BodyFormatter = {
|
||||
formatRequest?: (request: Request) => any;
|
||||
formatResponse?: (request: Request) => any;
|
||||
@@ -330,12 +195,12 @@ class RequestBodyInspector extends Component<{
|
||||
const component = formatter.formatRequest(request);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
<Layout.Container gap>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -366,12 +231,12 @@ class ResponseBodyInspector extends Component<{
|
||||
const component = formatter.formatResponse(request);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
<Layout.Container gap>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -386,17 +251,18 @@ class ResponseBodyInspector extends Component<{
|
||||
}
|
||||
}
|
||||
|
||||
const FormattedBy = styled(SmallText)({
|
||||
const FormattedBy = styled(Text)({
|
||||
marginTop: 8,
|
||||
fontSize: '0.7em',
|
||||
textAlign: 'center',
|
||||
display: 'block',
|
||||
color: theme.disabledColor,
|
||||
});
|
||||
|
||||
const Empty = () => (
|
||||
<BodyContainer>
|
||||
<Layout.Container pad>
|
||||
<Text>(empty)</Text>
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
|
||||
function getRequestData(request: Request) {
|
||||
@@ -420,29 +286,19 @@ function renderRawBody(request: Request, mode: 'request' | 'response') {
|
||||
mode === 'request' ? getRequestData(request) : getResponseData(request),
|
||||
);
|
||||
return (
|
||||
<BodyContainer>
|
||||
<Layout.Container gap>
|
||||
{decoded ? (
|
||||
<Text selectable wordWrap="break-word">
|
||||
{decoded}
|
||||
</Text>
|
||||
<CodeBlock>{decoded}</CodeBlock>
|
||||
) : (
|
||||
<>
|
||||
<FormattedBy>(Failed to decode)</FormattedBy>
|
||||
<Text selectable wordWrap="break-word">
|
||||
{data}
|
||||
</Text>
|
||||
<CodeBlock>{data}</CodeBlock>
|
||||
</>
|
||||
)}
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaContainer = styled(FlexColumn)({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
type ImageWithSizeProps = {
|
||||
src: string;
|
||||
};
|
||||
@@ -459,13 +315,8 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
static Text = styled(Text)({
|
||||
color: colors.dark70,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
constructor(props: ImageWithSizeProps, context: any) {
|
||||
super(props, context);
|
||||
constructor(props: ImageWithSizeProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -487,12 +338,12 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<Layout.Container center>
|
||||
<ImageWithSize.Image src={this.props.src} />
|
||||
<ImageWithSize.Text>
|
||||
<Text type="secondary">
|
||||
{this.state.width} x {this.state.height}
|
||||
</ImageWithSize.Text>
|
||||
</MediaContainer>
|
||||
</Text>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -528,44 +379,36 @@ class VideoFormatter {
|
||||
const contentType = getHeaderValue(request.responseHeaders, 'content-type');
|
||||
if (contentType.startsWith('video/')) {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<Layout.Container center>
|
||||
<VideoFormatter.Video controls={true}>
|
||||
<source src={request.url} type={contentType} />
|
||||
</VideoFormatter.Video>
|
||||
</MediaContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class JSONText extends Component<{children: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const jsonObject = this.props.children;
|
||||
return (
|
||||
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
<CodeBlock>
|
||||
{JSON.stringify(jsonObject, null, 2)}
|
||||
{'\n'}
|
||||
</JSONText.NoScrollbarText>
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XMLText extends Component<{body: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const xmlPretty = xmlBeautifier(this.props.body);
|
||||
return (
|
||||
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
<CodeBlock>
|
||||
{xmlPretty}
|
||||
{'\n'}
|
||||
</XMLText.NoScrollbarText>
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -652,20 +495,14 @@ class JSONFormatter {
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
return <DataInspector collapsed expandRoot data={data} />;
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const roots = body.split('\n');
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
<DataInspector
|
||||
collapsed
|
||||
expandRoot
|
||||
data={roots.map((json) => JSON.parse(json))}
|
||||
/>
|
||||
);
|
||||
@@ -681,7 +518,7 @@ class LogEventFormatter {
|
||||
if (typeof data.message === 'string') {
|
||||
data.message = JSON.parse(data.message);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
return <DataInspector expandRoot data={data} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,7 +530,7 @@ class GraphQLBatchFormatter {
|
||||
if (typeof data.queries === 'string') {
|
||||
data.queries = JSON.parse(data.queries);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
return <DataInspector expandRoot data={data} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,10 +554,10 @@ class GraphQLFormatter {
|
||||
const requestStartMs = serverMetadata['request_start_time_ms'];
|
||||
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
|
||||
return (
|
||||
<WrappingText>
|
||||
<Text>
|
||||
{'Server wall time for initial response (ms): ' +
|
||||
(timeAtFlushMs - requestStartMs)}
|
||||
</WrappingText>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
formatRequest(request: Request) {
|
||||
@@ -736,7 +573,7 @@ class GraphQLFormatter {
|
||||
if (typeof data.query_params === 'string') {
|
||||
data.query_params = JSON.parse(data.query_params);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
return <DataInspector expandRoot data={data} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,11 +597,7 @@ class GraphQLFormatter {
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(data)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
<DataInspector collapsed expandRoot data={data} />
|
||||
</div>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
@@ -776,11 +609,7 @@ class GraphQLFormatter {
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(parsedResponses)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={parsedResponses}
|
||||
/>
|
||||
<DataInspector collapsed expandRoot data={parsedResponses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -796,12 +625,7 @@ class FormUrlencodedFormatter {
|
||||
if (!decoded) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
expandRoot={true}
|
||||
data={querystring.parse(decoded)}
|
||||
/>
|
||||
);
|
||||
return <DataInspector expandRoot data={querystring.parse(decoded)} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -921,13 +745,13 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
return `${formatBytes(value)}/sec`;
|
||||
}
|
||||
|
||||
formatRetries(retry: RetryInsights): string {
|
||||
formatRetries = (retry: RetryInsights): string => {
|
||||
const timesWord = retry.limit === 1 ? 'time' : 'times';
|
||||
|
||||
return `${this.formatTime(retry.timeSpent)} (${
|
||||
retry.count
|
||||
} ${timesWord} out of ${retry.limit})`;
|
||||
}
|
||||
};
|
||||
|
||||
buildRow<T>(
|
||||
name: string,
|
||||
@@ -936,16 +760,8 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
): any {
|
||||
return value
|
||||
? {
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{name}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{formatter(value)}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: () => `${name}: ${formatter(value)}`,
|
||||
key: name,
|
||||
value: formatter(value),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -955,7 +771,7 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
|
||||
|
||||
const rows = [
|
||||
buildRow('Retries', insights.retries, formatRetries.bind(this)),
|
||||
buildRow('Retries', insights.retries, formatRetries),
|
||||
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
|
||||
buildRow('Connect time', insights.connectTime, formatTime),
|
||||
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
|
||||
@@ -968,16 +784,6 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
|
||||
].filter((r) => r != null);
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,42 +7,12 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React, {createContext, createRef} from 'react';
|
||||
import {Menu, message} from 'antd';
|
||||
import React, {createRef} from 'react';
|
||||
import {Button, Menu, Modal, Typography} from 'antd';
|
||||
|
||||
import {
|
||||
Layout,
|
||||
Button,
|
||||
Glyph,
|
||||
colors,
|
||||
DetailSidebar,
|
||||
styled,
|
||||
Sheet,
|
||||
} from 'flipper';
|
||||
import {
|
||||
Request,
|
||||
RequestInfo,
|
||||
ResponseInfo,
|
||||
Route,
|
||||
ResponseFollowupChunk,
|
||||
Header,
|
||||
MockRoute,
|
||||
AddProtobufEvent,
|
||||
PartialResponses,
|
||||
Requests,
|
||||
} from './types';
|
||||
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
||||
import {
|
||||
convertRequestToCurlCommand,
|
||||
getHeaderValue,
|
||||
decodeBody,
|
||||
getResponseLength,
|
||||
} from './utils';
|
||||
import RequestDetails from './RequestDetails';
|
||||
import {URL} from 'url';
|
||||
import {MockResponseDialog} from './MockResponseDialog';
|
||||
import {assembleChunksIfResponseIsComplete} from './chunks';
|
||||
import {
|
||||
PluginClient,
|
||||
Device,
|
||||
createState,
|
||||
@@ -52,20 +22,49 @@ import {
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
DataTableManager,
|
||||
theme,
|
||||
} from 'flipper-plugin';
|
||||
import fs from 'fs';
|
||||
// eslint-disable-next-line
|
||||
import electron, {OpenDialogOptions, remote} from 'electron';
|
||||
import {
|
||||
Request,
|
||||
RequestInfo,
|
||||
ResponseInfo,
|
||||
ResponseFollowupChunk,
|
||||
AddProtobufEvent,
|
||||
PartialResponses,
|
||||
} from './types';
|
||||
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
||||
import {
|
||||
convertRequestToCurlCommand,
|
||||
getHeaderValue,
|
||||
getResponseLength,
|
||||
formatStatus,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
requestsToText,
|
||||
} from './utils';
|
||||
import RequestDetails from './RequestDetails';
|
||||
import {URL} from 'url';
|
||||
import {assembleChunksIfResponseIsComplete} from './chunks';
|
||||
import {DeleteOutlined} from '@ant-design/icons';
|
||||
import {ManageMockResponsePanel} from './request-mocking/ManageMockResponsePanel';
|
||||
import {
|
||||
NetworkRouteContext,
|
||||
NetworkRouteManager,
|
||||
nullNetworkRouteManager,
|
||||
Route,
|
||||
MockRoute,
|
||||
createNetworkManager,
|
||||
computeMockRoutes,
|
||||
} from './request-mocking/NetworkRouteManager';
|
||||
|
||||
const LOCALSTORAGE_MOCK_ROUTE_LIST_KEY = '__NETWORK_CACHED_MOCK_ROUTE_LIST';
|
||||
const LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY =
|
||||
'__NETWORK_CACHED_RESPONSE_BODY_FORMAT';
|
||||
|
||||
export const BodyOptions = {
|
||||
formatted: 'formatted',
|
||||
parsed: 'parsed',
|
||||
};
|
||||
export const BodyOptions = ['formatted', 'parsed'].map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
|
||||
type Events = {
|
||||
newRequest: RequestInfo;
|
||||
@@ -78,58 +77,6 @@ type Methods = {
|
||||
mockResponses(params: {routes: MockRoute[]}): Promise<void>;
|
||||
};
|
||||
|
||||
const mockingStyle = {
|
||||
backgroundColor: colors.yellowTint,
|
||||
color: colors.yellow,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const errorStyle = {
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
export function formatBytes(count: number | undefined): string {
|
||||
if (typeof count !== 'number') {
|
||||
return '';
|
||||
}
|
||||
if (count > 1024 * 1024) {
|
||||
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
if (count > 1024) {
|
||||
return (count / 1024.0).toFixed(1) + ' kB';
|
||||
}
|
||||
return count + ' B';
|
||||
}
|
||||
|
||||
// State management
|
||||
export interface NetworkRouteManager {
|
||||
addRoute(): string | null;
|
||||
modifyRoute(id: string, routeChange: Partial<Route>): void;
|
||||
removeRoute(id: string): void;
|
||||
enableRoute(id: string): void;
|
||||
copyHighlightedCalls(highlightedRows: Set<string>, requests: Requests): void;
|
||||
importRoutes(): void;
|
||||
exportRoutes(): void;
|
||||
clearRoutes(): void;
|
||||
}
|
||||
const nullNetworkRouteManager: NetworkRouteManager = {
|
||||
addRoute(): string | null {
|
||||
return '';
|
||||
},
|
||||
modifyRoute(_id: string, _routeChange: Partial<Route>) {},
|
||||
removeRoute(_id: string) {},
|
||||
enableRoute(_id: string) {},
|
||||
copyHighlightedCalls(_highlightedRows: Set<string>, _requests: Requests) {},
|
||||
importRoutes() {},
|
||||
exportRoutes() {},
|
||||
clearRoutes() {},
|
||||
};
|
||||
export const NetworkRouteContext = createContext<NetworkRouteManager>(
|
||||
nullNetworkRouteManager,
|
||||
);
|
||||
|
||||
export function plugin(client: PluginClient<Events, Methods>) {
|
||||
const networkRouteManager = createState<NetworkRouteManager>(
|
||||
nullNetworkRouteManager,
|
||||
@@ -137,11 +84,12 @@ export function plugin(client: PluginClient<Events, Methods>) {
|
||||
|
||||
const routes = createState<{[id: string]: Route}>({});
|
||||
const nextRouteId = createState<number>(0);
|
||||
const isMockResponseSupported = createState<boolean>(false);
|
||||
const isMockResponseSupported = createState<boolean>(false, {
|
||||
persist: 'isMockResponseSupported',
|
||||
});
|
||||
const showMockResponseDialog = createState<boolean>(false);
|
||||
const detailBodyFormat = createState<string>(
|
||||
localStorage.getItem(LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY) ||
|
||||
BodyOptions.parsed,
|
||||
localStorage.getItem(LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY) || 'parsed',
|
||||
);
|
||||
const requests = createDataSource<Request, 'id'>([], {
|
||||
key: 'id',
|
||||
@@ -250,9 +198,9 @@ export function plugin(client: PluginClient<Events, Methods>) {
|
||||
}
|
||||
});
|
||||
|
||||
function supportsMocks(device: Device): Promise<boolean> {
|
||||
async function supportsMocks(device: Device): Promise<boolean> {
|
||||
if (device.isArchived) {
|
||||
return Promise.resolve(true);
|
||||
return isMockResponseSupported.get();
|
||||
} else {
|
||||
return client.supportsMethod('mockResponses');
|
||||
}
|
||||
@@ -273,157 +221,14 @@ export function plugin(client: PluginClient<Events, Methods>) {
|
||||
});
|
||||
|
||||
// declare new variable to be called inside the interface
|
||||
networkRouteManager.set({
|
||||
addRoute(): string | null {
|
||||
const newNextRouteId = nextRouteId.get();
|
||||
routes.update((draft) => {
|
||||
draft[newNextRouteId.toString()] = {
|
||||
requestUrl: '',
|
||||
requestMethod: 'GET',
|
||||
responseData: '',
|
||||
responseHeaders: {},
|
||||
responseStatus: '200',
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
nextRouteId.set(newNextRouteId + 1);
|
||||
return String(newNextRouteId);
|
||||
},
|
||||
modifyRoute(id: string, routeChange: Partial<Route>) {
|
||||
if (!routes.get().hasOwnProperty(id)) {
|
||||
return;
|
||||
}
|
||||
routes.update((draft) => {
|
||||
Object.assign(draft[id], routeChange);
|
||||
});
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
removeRoute(id: string) {
|
||||
if (routes.get().hasOwnProperty(id)) {
|
||||
routes.update((draft) => {
|
||||
delete draft[id];
|
||||
});
|
||||
}
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
enableRoute(id: string) {
|
||||
if (routes.get().hasOwnProperty(id)) {
|
||||
routes.update((draft) => {
|
||||
draft[id].enabled = !draft[id].enabled;
|
||||
});
|
||||
}
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
copyHighlightedCalls(
|
||||
highlightedRows: Set<string> | null | undefined,
|
||||
requests: Requests,
|
||||
) {
|
||||
// iterate through highlighted rows
|
||||
highlightedRows?.forEach((row) => {
|
||||
const request = requests.getById(row);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
// convert headers
|
||||
const headers: {[id: string]: Header} = {};
|
||||
request.responseHeaders?.forEach((e) => {
|
||||
headers[e.key] = e;
|
||||
});
|
||||
|
||||
// convert data TODO: we only want this for non-binary data! See D23403095
|
||||
const responseData =
|
||||
request && request.responseData
|
||||
? decodeBody({
|
||||
headers: request.responseHeaders ?? [],
|
||||
data: request.responseData,
|
||||
})
|
||||
: '';
|
||||
|
||||
const newNextRouteId = nextRouteId.get();
|
||||
routes.update((draft) => {
|
||||
draft[newNextRouteId.toString()] = {
|
||||
requestUrl: request.url,
|
||||
requestMethod: request.method,
|
||||
responseData: responseData as string,
|
||||
responseHeaders: headers,
|
||||
responseStatus: request.status?.toString() ?? '',
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
nextRouteId.set(newNextRouteId + 1);
|
||||
});
|
||||
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
importRoutes() {
|
||||
const options: OpenDialogOptions = {
|
||||
properties: ['openFile'],
|
||||
filters: [{extensions: ['json'], name: 'Flipper Route Files'}],
|
||||
};
|
||||
remote.dialog.showOpenDialog(options).then((result) => {
|
||||
const filePaths = result.filePaths;
|
||||
if (filePaths.length > 0) {
|
||||
fs.readFile(filePaths[0], 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
message.error('Unable to import file');
|
||||
return;
|
||||
}
|
||||
const importedRoutes = JSON.parse(data);
|
||||
importedRoutes?.forEach((importedRoute: Route) => {
|
||||
if (importedRoute != null) {
|
||||
const newNextRouteId = nextRouteId.get();
|
||||
routes.update((draft) => {
|
||||
draft[newNextRouteId.toString()] = {
|
||||
requestUrl: importedRoute.requestUrl,
|
||||
requestMethod: importedRoute.requestMethod,
|
||||
responseData: importedRoute.responseData as string,
|
||||
responseHeaders: importedRoute.responseHeaders,
|
||||
responseStatus: importedRoute.responseStatus,
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
nextRouteId.set(newNextRouteId + 1);
|
||||
}
|
||||
});
|
||||
informClientMockChange(routes.get());
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
exportRoutes() {
|
||||
remote.dialog
|
||||
.showSaveDialog(
|
||||
// @ts-ignore This appears to work but isn't allowed by the types
|
||||
null,
|
||||
{
|
||||
title: 'Export Routes',
|
||||
defaultPath: 'NetworkPluginRoutesExport.json',
|
||||
},
|
||||
)
|
||||
.then((result: electron.SaveDialogReturnValue) => {
|
||||
const file = result.filePath;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
fs.writeFile(
|
||||
file,
|
||||
JSON.stringify(Object.values(routes.get()), null, 2),
|
||||
'utf8',
|
||||
(err) => {
|
||||
if (err) {
|
||||
message.error('Failed to store mock routes: ' + err);
|
||||
} else {
|
||||
message.info('Successfully exported mock routes');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
clearRoutes() {
|
||||
routes.set({});
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
});
|
||||
networkRouteManager.set(
|
||||
createNetworkManager(
|
||||
nextRouteId,
|
||||
routes,
|
||||
informClientMockChange,
|
||||
tableManagerRef,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
@@ -431,29 +236,7 @@ export function plugin(client: PluginClient<Events, Methods>) {
|
||||
}
|
||||
|
||||
async function informClientMockChange(routes: {[id: string]: Route}) {
|
||||
const existedIdSet: {[id: string]: {[method: string]: boolean}} = {};
|
||||
const filteredRoutes: {[id: string]: Route} = Object.entries(routes).reduce(
|
||||
(accRoutes, [id, route]) => {
|
||||
if (existedIdSet.hasOwnProperty(route.requestUrl)) {
|
||||
if (
|
||||
existedIdSet[route.requestUrl].hasOwnProperty(route.requestMethod)
|
||||
) {
|
||||
return accRoutes;
|
||||
}
|
||||
existedIdSet[route.requestUrl] = {
|
||||
...existedIdSet[route.requestUrl],
|
||||
[route.requestMethod]: true,
|
||||
};
|
||||
return Object.assign({[id]: route}, accRoutes);
|
||||
} else {
|
||||
existedIdSet[route.requestUrl] = {
|
||||
[route.requestMethod]: true,
|
||||
};
|
||||
return Object.assign({[id]: route}, accRoutes);
|
||||
}
|
||||
},
|
||||
{},
|
||||
);
|
||||
const filteredRoutes: {[id: string]: Route} = computeMockRoutes(routes);
|
||||
|
||||
if (isMockResponseSupported.get()) {
|
||||
const routesValuesArray = Object.values(filteredRoutes);
|
||||
@@ -584,7 +367,7 @@ export function Component() {
|
||||
onRowStyle={getRowStyle}
|
||||
tableManagerRef={instance.tableManagerRef}
|
||||
onSelect={instance.onSelect}
|
||||
onCopyRows={copyRow}
|
||||
onCopyRows={requestsToText}
|
||||
onContextMenu={instance.onContextMenu}
|
||||
enableAutoScroll
|
||||
extraActions={
|
||||
@@ -598,33 +381,46 @@ export function Component() {
|
||||
</Layout.Horizontal>
|
||||
}
|
||||
/>
|
||||
{showMockResponseDialog ? (
|
||||
<Sheet>
|
||||
{(onHide) => (
|
||||
<MockResponseDialog
|
||||
routes={routes}
|
||||
onHide={() => {
|
||||
onHide();
|
||||
instance.onCloseButtonPressed();
|
||||
}}
|
||||
highlightedRows={
|
||||
new Set(
|
||||
instance.tableManagerRef
|
||||
.current!.getSelectedItems()
|
||||
.map((r) => r.id),
|
||||
)
|
||||
}
|
||||
requests={instance.requests}
|
||||
/>
|
||||
)}
|
||||
</Sheet>
|
||||
) : null}
|
||||
<Sidebar />
|
||||
<Modal
|
||||
visible={showMockResponseDialog}
|
||||
onCancel={instance.onCloseButtonPressed}
|
||||
footer={null}
|
||||
title="Mock Network Responses"
|
||||
width={1200}>
|
||||
<ManageMockResponsePanel routes={routes} />
|
||||
</Modal>
|
||||
<DetailSidebar width={400}>
|
||||
<Sidebar />
|
||||
</DetailSidebar>
|
||||
</Layout.Container>
|
||||
</NetworkRouteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const instance = usePlugin(plugin);
|
||||
const selectedId = useValue(instance.selectedId);
|
||||
const detailBodyFormat = useValue(instance.detailBodyFormat);
|
||||
|
||||
const request = instance.requests.getById(selectedId!);
|
||||
if (!request) {
|
||||
return (
|
||||
<Layout.Container pad grow center>
|
||||
<Typography.Text type="secondary">No request selected</Typography.Text>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RequestDetails
|
||||
key={selectedId}
|
||||
request={request}
|
||||
bodyFormat={detailBodyFormat}
|
||||
onSelectFormat={instance.onSelectFormat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<Request>[] = [
|
||||
{
|
||||
key: 'requestTime',
|
||||
@@ -673,6 +469,14 @@ const columns: DataTableColumn<Request>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockingStyle = {
|
||||
color: theme.warningColor,
|
||||
};
|
||||
|
||||
const errorStyle = {
|
||||
color: theme.errorColor,
|
||||
};
|
||||
|
||||
function getRowStyle(row: Request) {
|
||||
return row.responseIsMock
|
||||
? mockingStyle
|
||||
@@ -680,101 +484,3 @@ function getRowStyle(row: Request) {
|
||||
? errorStyle
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function copyRow(requests: Request[]): string {
|
||||
const request = requests[0];
|
||||
if (!request || !request.url) {
|
||||
return '<empty request>';
|
||||
}
|
||||
|
||||
let copyText = `# HTTP request for ${request.domain} (ID: ${request.id})
|
||||
## Request
|
||||
HTTP ${request.method} ${request.url}
|
||||
${request.requestHeaders
|
||||
.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
|
||||
// TODO: we want decoding only for non-binary data! See D23403095
|
||||
const requestData = request.requestData
|
||||
? decodeBody({
|
||||
headers: request.requestHeaders,
|
||||
data: request.requestData,
|
||||
})
|
||||
: null;
|
||||
const responseData = request.responseData
|
||||
? decodeBody({
|
||||
headers: request.responseHeaders,
|
||||
data: request.responseData,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (requestData) {
|
||||
copyText += `\n\n${requestData}`;
|
||||
}
|
||||
if (request.status) {
|
||||
copyText += `
|
||||
|
||||
## Response
|
||||
HTTP ${request.status} ${request.reason}
|
||||
${
|
||||
request.responseHeaders
|
||||
?.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n') ?? ''
|
||||
}`;
|
||||
}
|
||||
|
||||
if (responseData) {
|
||||
copyText += `\n\n${responseData}`;
|
||||
}
|
||||
return copyText;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const instance = usePlugin(plugin);
|
||||
const selectedId = useValue(instance.selectedId);
|
||||
const detailBodyFormat = useValue(instance.detailBodyFormat);
|
||||
|
||||
const request = instance.requests.getById(selectedId!);
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailSidebar width={500}>
|
||||
<RequestDetails
|
||||
key={selectedId}
|
||||
request={request}
|
||||
bodyFormat={detailBodyFormat}
|
||||
onSelectFormat={instance.onSelectFormat}
|
||||
/>
|
||||
</DetailSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginTop: -3,
|
||||
marginRight: 3,
|
||||
});
|
||||
|
||||
function formatStatus(status: number | undefined) {
|
||||
if (typeof status === 'number' && status >= 400 && status < 600) {
|
||||
return (
|
||||
<>
|
||||
<Icon name="stop" color={colors.red} />
|
||||
{status}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function formatDuration(duration: number | undefined) {
|
||||
if (typeof duration === 'number') return duration + 'ms';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import {MockResponseDetails} from './MockResponseDetails';
|
||||
import {NetworkRouteContext, Route} from './NetworkRouteManager';
|
||||
import {RequestId} from '../types';
|
||||
import {Checkbox, Modal, Tooltip, Button, Typography} from 'antd';
|
||||
import {
|
||||
NUX,
|
||||
Layout,
|
||||
DataList,
|
||||
Toolbar,
|
||||
createState,
|
||||
useValue,
|
||||
} from 'flipper-plugin';
|
||||
import {CloseCircleOutlined, WarningOutlined} from '@ant-design/icons';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
type Props = {
|
||||
routes: {[id: string]: Route};
|
||||
};
|
||||
|
||||
type RouteItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
route: Route;
|
||||
isDuplicate: boolean;
|
||||
};
|
||||
|
||||
// return ids that have the same pair of requestUrl and method; this will return only the duplicate
|
||||
function _duplicateIds(routes: {[id: string]: Route}): Array<RequestId> {
|
||||
const idSet: {[id: string]: {[method: string]: boolean}} = {};
|
||||
return Object.entries(routes).reduce((acc: Array<RequestId>, [id, route]) => {
|
||||
if (idSet.hasOwnProperty(route.requestUrl)) {
|
||||
if (idSet[route.requestUrl].hasOwnProperty(route.requestMethod)) {
|
||||
return acc.concat(id);
|
||||
}
|
||||
idSet[route.requestUrl] = {
|
||||
...idSet[route.requestUrl],
|
||||
[route.requestMethod]: true,
|
||||
};
|
||||
return acc;
|
||||
} else {
|
||||
idSet[route.requestUrl] = {[route.requestMethod]: true};
|
||||
return acc;
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function ManageMockResponsePanel(props: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
const [selectedIdAtom] = useState(() => createState<RequestId | undefined>());
|
||||
const selectedId = useValue(selectedIdAtom);
|
||||
|
||||
useEffect(() => {
|
||||
selectedIdAtom.update((selectedId) => {
|
||||
const keys = Object.keys(props.routes);
|
||||
let returnValue: string | undefined = undefined;
|
||||
// selectId is undefined when there are no rows or it is the first time rows are shown
|
||||
if (selectedId === undefined) {
|
||||
if (keys.length === 0) {
|
||||
// there are no rows
|
||||
returnValue = undefined;
|
||||
} else {
|
||||
// first time rows are shown
|
||||
returnValue = keys[0];
|
||||
}
|
||||
} else {
|
||||
if (keys.includes(selectedId)) {
|
||||
returnValue = selectedId;
|
||||
} else {
|
||||
// selectedId row value not in routes so default to first line
|
||||
returnValue = keys[0];
|
||||
}
|
||||
}
|
||||
return returnValue;
|
||||
});
|
||||
}, [props.routes, selectedIdAtom]);
|
||||
const duplicatedIds = useMemo(() => _duplicateIds(props.routes), [
|
||||
props.routes,
|
||||
]);
|
||||
|
||||
const items: RouteItem[] = Object.entries(props.routes).map(
|
||||
([id, route]) => ({
|
||||
id,
|
||||
route,
|
||||
title: route.requestUrl,
|
||||
isDuplicate: duplicatedIds.includes(id),
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
Modal.confirm({
|
||||
title: 'Are you sure you want to delete this item?',
|
||||
icon: '',
|
||||
onOk() {
|
||||
networkRouteManager.removeRoute(id);
|
||||
selectedIdAtom.set(undefined);
|
||||
},
|
||||
onCancel() {},
|
||||
});
|
||||
},
|
||||
[networkRouteManager, selectedIdAtom],
|
||||
);
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
networkRouteManager.enableRoute(id);
|
||||
},
|
||||
[networkRouteManager],
|
||||
);
|
||||
|
||||
const handleRender = useCallback(
|
||||
(item: RouteItem) => (
|
||||
<RouteEntry item={item} onDelete={handleDelete} onToggle={handleToggle} />
|
||||
),
|
||||
[handleDelete, handleToggle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout.Left resizable style={{minHeight: 400}}>
|
||||
<Layout.Top>
|
||||
<Toolbar>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newId = networkRouteManager.addRoute();
|
||||
selectedIdAtom.set(newId);
|
||||
}}>
|
||||
Add Route
|
||||
</Button>
|
||||
<NUX
|
||||
title="It is now possible to select calls from the network call list and convert them into mock routes."
|
||||
placement="bottom">
|
||||
<Button
|
||||
onClick={() => {
|
||||
networkRouteManager.copyHighlightedCalls();
|
||||
}}>
|
||||
Copy Highlighted Calls
|
||||
</Button>
|
||||
</NUX>
|
||||
<Button onClick={networkRouteManager.importRoutes}>Import</Button>
|
||||
<Button onClick={networkRouteManager.exportRoutes}>Export</Button>
|
||||
<Button onClick={networkRouteManager.clearRoutes}>Clear</Button>
|
||||
</Toolbar>
|
||||
<DataList
|
||||
items={items}
|
||||
selection={selectedIdAtom}
|
||||
onRenderItem={handleRender}
|
||||
scrollable
|
||||
/>
|
||||
</Layout.Top>
|
||||
<Layout.Container gap pad>
|
||||
{selectedId && props.routes.hasOwnProperty(selectedId) && (
|
||||
<MockResponseDetails
|
||||
id={selectedId}
|
||||
route={props.routes[selectedId]}
|
||||
isDuplicated={duplicatedIds.includes(selectedId)}
|
||||
/>
|
||||
)}
|
||||
</Layout.Container>
|
||||
</Layout.Left>
|
||||
);
|
||||
}
|
||||
|
||||
const RouteEntry = ({
|
||||
item,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
item: RouteItem;
|
||||
onToggle(id: string): void;
|
||||
onDelete(id: string): void;
|
||||
}) => {
|
||||
const tip = item.route.enabled
|
||||
? 'Un-check to disable mock route'
|
||||
: 'Check to enable mock route';
|
||||
return (
|
||||
<Layout.Horizontal gap center>
|
||||
<Tooltip title={tip} mouseEnterDelay={1.1}>
|
||||
<Checkbox
|
||||
onClick={() => onToggle(item.id)}
|
||||
checked={item.route.enabled}></Checkbox>
|
||||
</Tooltip>
|
||||
{item.route.requestUrl.length === 0 ? (
|
||||
<Text ellipsis>untitled</Text>
|
||||
) : (
|
||||
<Text ellipsis>{item.route.requestUrl}</Text>
|
||||
)}
|
||||
<Tooltip title="Click to delete mock route" mouseEnterDelay={1.1}>
|
||||
<Layout.Horizontal onClick={() => onDelete(item.id)}>
|
||||
<CloseCircleOutlined />
|
||||
</Layout.Horizontal>
|
||||
</Tooltip>
|
||||
{item.isDuplicate && <WarningOutlined />}
|
||||
</Layout.Horizontal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React, {useContext, useState} from 'react';
|
||||
import {
|
||||
NetworkRouteContext,
|
||||
NetworkRouteManager,
|
||||
Route,
|
||||
} from './NetworkRouteManager';
|
||||
import {RequestId} from '../types';
|
||||
import {Button, Input, Select} from 'antd';
|
||||
import {Layout, produce, Tabs, Tab, theme} from 'flipper-plugin';
|
||||
import {CloseCircleOutlined, WarningOutlined} from '@ant-design/icons';
|
||||
|
||||
type Props = {
|
||||
id: RequestId;
|
||||
route: Route;
|
||||
isDuplicated: boolean;
|
||||
};
|
||||
|
||||
function HeaderInput(props: {
|
||||
initialValue: string;
|
||||
onUpdate: (newValue: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [value, setValue] = useState(props.initialValue);
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => props.onUpdate(value)}
|
||||
style={props.style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseHeaders({
|
||||
routeId,
|
||||
route,
|
||||
networkRouteManager,
|
||||
}: {
|
||||
routeId: string;
|
||||
route: Route;
|
||||
networkRouteManager: NetworkRouteManager;
|
||||
}) {
|
||||
return (
|
||||
<Layout.Container gap style={{paddingRight: theme.space.small}}>
|
||||
{Object.entries(route.responseHeaders).map(([id, header]) => (
|
||||
<Layout.Horizontal center gap key={id}>
|
||||
<HeaderInput
|
||||
initialValue={header.key}
|
||||
onUpdate={(newValue: string) => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
draftHeaders[id].key = newValue;
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
style={{width: 300}}
|
||||
/>
|
||||
<HeaderInput
|
||||
initialValue={header.value}
|
||||
onUpdate={(newValue: string) => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
draftHeaders[id].value = newValue;
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Layout.Container
|
||||
onClick={() => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
delete draftHeaders[id];
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}>
|
||||
<CloseCircleOutlined />
|
||||
</Layout.Container>
|
||||
</Layout.Horizontal>
|
||||
))}
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
const httpMethods = [
|
||||
'GET',
|
||||
'POST',
|
||||
'PATCH',
|
||||
'HEAD',
|
||||
'PUT',
|
||||
'DELETE',
|
||||
'TRACE',
|
||||
'OPTIONS',
|
||||
'CONNECT',
|
||||
].map((v) => ({value: v, label: v}));
|
||||
|
||||
export function MockResponseDetails({id, route, isDuplicated}: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
const [nextHeaderId, setNextHeaderId] = useState(0);
|
||||
|
||||
const {requestUrl, requestMethod, responseData, responseStatus} = route;
|
||||
|
||||
let formattedResponse = '';
|
||||
try {
|
||||
formattedResponse = JSON.stringify(JSON.parse(responseData), null, 2);
|
||||
} catch (e) {
|
||||
formattedResponse = responseData;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Container gap>
|
||||
<Layout.Horizontal gap>
|
||||
<Select
|
||||
value={requestMethod}
|
||||
options={httpMethods}
|
||||
onChange={(text) =>
|
||||
networkRouteManager.modifyRoute(id, {requestMethod: text})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
value={requestUrl}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
requestUrl: event.target.value,
|
||||
})
|
||||
}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="STATUS"
|
||||
value={responseStatus}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseStatus: event.target.value,
|
||||
})
|
||||
}
|
||||
style={{width: 100}}
|
||||
/>
|
||||
</Layout.Horizontal>
|
||||
{isDuplicated && (
|
||||
<Layout.Horizontal gap>
|
||||
<WarningOutlined />
|
||||
Route is duplicated (Same URL and Method)
|
||||
</Layout.Horizontal>
|
||||
)}
|
||||
<Layout.Container height={500}>
|
||||
<Tabs grow>
|
||||
<Tab tab={'Data'}>
|
||||
<Input.TextArea
|
||||
wrap="soft"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={formattedResponse}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseData: event.target.value,
|
||||
})
|
||||
}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab tab={'Headers'}>
|
||||
<Layout.Top gap>
|
||||
<Layout.Horizontal>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newHeaders = {
|
||||
...route.responseHeaders,
|
||||
[nextHeaderId.toString()]: {key: '', value: ''},
|
||||
};
|
||||
setNextHeaderId(nextHeaderId + 1);
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}>
|
||||
Add Header
|
||||
</Button>
|
||||
</Layout.Horizontal>
|
||||
<Layout.ScrollContainer>
|
||||
<ResponseHeaders
|
||||
routeId={id}
|
||||
route={route}
|
||||
networkRouteManager={networkRouteManager}
|
||||
/>
|
||||
</Layout.ScrollContainer>
|
||||
</Layout.Top>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Layout.Container>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
// eslint-disable-next-line
|
||||
import electron, {OpenDialogOptions, remote} from 'electron';
|
||||
import {Atom, DataTableManager} from 'flipper-plugin';
|
||||
import {createContext} from 'react';
|
||||
import {Header, Request} from '../types';
|
||||
import {decodeBody} from '../utils';
|
||||
import {message} from 'antd';
|
||||
|
||||
export type Route = {
|
||||
requestUrl: string;
|
||||
requestMethod: string;
|
||||
responseData: string;
|
||||
responseHeaders: {[id: string]: Header};
|
||||
responseStatus: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type MockRoute = {
|
||||
requestUrl: string;
|
||||
method: string;
|
||||
data: string;
|
||||
headers: Header[];
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export interface NetworkRouteManager {
|
||||
addRoute(): string | undefined;
|
||||
modifyRoute(id: string, routeChange: Partial<Route>): void;
|
||||
removeRoute(id: string): void;
|
||||
enableRoute(id: string): void;
|
||||
copyHighlightedCalls(): void;
|
||||
importRoutes(): void;
|
||||
exportRoutes(): void;
|
||||
clearRoutes(): void;
|
||||
}
|
||||
|
||||
export const nullNetworkRouteManager: NetworkRouteManager = {
|
||||
addRoute(): string | undefined {
|
||||
return '';
|
||||
},
|
||||
modifyRoute(_id: string, _routeChange: Partial<Route>) {},
|
||||
removeRoute(_id: string) {},
|
||||
enableRoute(_id: string) {},
|
||||
copyHighlightedCalls() {},
|
||||
importRoutes() {},
|
||||
exportRoutes() {},
|
||||
clearRoutes() {},
|
||||
};
|
||||
|
||||
export const NetworkRouteContext = createContext<NetworkRouteManager>(
|
||||
nullNetworkRouteManager,
|
||||
);
|
||||
|
||||
export function createNetworkManager(
|
||||
nextRouteId: Atom<number>,
|
||||
routes: Atom<{[id: string]: any}>,
|
||||
informClientMockChange: (routes: {[id: string]: any}) => Promise<void>,
|
||||
tableManagerRef: React.RefObject<DataTableManager<Request> | undefined>,
|
||||
): NetworkRouteManager {
|
||||
return {
|
||||
addRoute(): string | undefined {
|
||||
const newNextRouteId = nextRouteId.get();
|
||||
routes.update((draft) => {
|
||||
draft[newNextRouteId.toString()] = {
|
||||
requestUrl: '',
|
||||
requestMethod: 'GET',
|
||||
responseData: '',
|
||||
responseHeaders: {},
|
||||
responseStatus: '200',
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
nextRouteId.set(newNextRouteId + 1);
|
||||
return String(newNextRouteId);
|
||||
},
|
||||
modifyRoute(id: string, routeChange: Partial<Route>) {
|
||||
if (!routes.get().hasOwnProperty(id)) {
|
||||
return;
|
||||
}
|
||||
routes.update((draft) => {
|
||||
Object.assign(draft[id], routeChange);
|
||||
});
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
removeRoute(id: string) {
|
||||
if (routes.get().hasOwnProperty(id)) {
|
||||
routes.update((draft) => {
|
||||
delete draft[id];
|
||||
});
|
||||
}
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
enableRoute(id: string) {
|
||||
if (routes.get().hasOwnProperty(id)) {
|
||||
routes.update((draft) => {
|
||||
draft[id].enabled = !draft[id].enabled;
|
||||
});
|
||||
}
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
copyHighlightedCalls() {
|
||||
tableManagerRef.current?.getSelectedItems().forEach((request) => {
|
||||
// convert headers
|
||||
const headers: {[id: string]: Header} = {};
|
||||
request.responseHeaders?.forEach((e) => {
|
||||
headers[e.key] = e;
|
||||
});
|
||||
|
||||
// convert data TODO: we only want this for non-binary data! See D23403095
|
||||
const responseData =
|
||||
request && request.responseData
|
||||
? decodeBody({
|
||||
headers: request.responseHeaders ?? [],
|
||||
data: request.responseData,
|
||||
})
|
||||
: '';
|
||||
|
||||
const newNextRouteId = nextRouteId.get();
|
||||
routes.update((draft) => {
|
||||
draft[newNextRouteId.toString()] = {
|
||||
requestUrl: request.url,
|
||||
requestMethod: request.method,
|
||||
responseData: responseData as string,
|
||||
responseHeaders: headers,
|
||||
responseStatus: request.status?.toString() ?? '',
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
nextRouteId.set(newNextRouteId + 1);
|
||||
});
|
||||
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
importRoutes() {
|
||||
const options: OpenDialogOptions = {
|
||||
properties: ['openFile'],
|
||||
filters: [{extensions: ['json'], name: 'Flipper Route Files'}],
|
||||
};
|
||||
remote.dialog.showOpenDialog(options).then((result) => {
|
||||
const filePaths = result.filePaths;
|
||||
if (filePaths.length > 0) {
|
||||
fs.readFile(filePaths[0], 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
message.error('Unable to import file');
|
||||
return;
|
||||
}
|
||||
const importedRoutes = JSON.parse(data);
|
||||
importedRoutes?.forEach((importedRoute: Route) => {
|
||||
if (importedRoute != null) {
|
||||
const newNextRouteId = nextRouteId.get();
|
||||
routes.update((draft) => {
|
||||
draft[newNextRouteId.toString()] = {
|
||||
requestUrl: importedRoute.requestUrl,
|
||||
requestMethod: importedRoute.requestMethod,
|
||||
responseData: importedRoute.responseData as string,
|
||||
responseHeaders: importedRoute.responseHeaders,
|
||||
responseStatus: importedRoute.responseStatus,
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
nextRouteId.set(newNextRouteId + 1);
|
||||
}
|
||||
});
|
||||
informClientMockChange(routes.get());
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
exportRoutes() {
|
||||
remote.dialog
|
||||
.showSaveDialog(
|
||||
// @ts-ignore This appears to work but isn't allowed by the types
|
||||
null,
|
||||
{
|
||||
title: 'Export Routes',
|
||||
defaultPath: 'NetworkPluginRoutesExport.json',
|
||||
},
|
||||
)
|
||||
.then((result: electron.SaveDialogReturnValue) => {
|
||||
const file = result.filePath;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
fs.writeFile(
|
||||
file,
|
||||
JSON.stringify(Object.values(routes.get()), null, 2),
|
||||
'utf8',
|
||||
(err) => {
|
||||
if (err) {
|
||||
message.error('Failed to store mock routes: ' + err);
|
||||
} else {
|
||||
message.info('Successfully exported mock routes');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
clearRoutes() {
|
||||
routes.set({});
|
||||
informClientMockChange(routes.get());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function computeMockRoutes(routes: {[id: string]: Route}) {
|
||||
const existedIdSet: {[id: string]: {[method: string]: boolean}} = {};
|
||||
const filteredRoutes: {[id: string]: Route} = Object.entries(routes).reduce(
|
||||
(accRoutes, [id, route]) => {
|
||||
if (existedIdSet.hasOwnProperty(route.requestUrl)) {
|
||||
if (
|
||||
existedIdSet[route.requestUrl].hasOwnProperty(route.requestMethod)
|
||||
) {
|
||||
return accRoutes;
|
||||
}
|
||||
existedIdSet[route.requestUrl] = {
|
||||
...existedIdSet[route.requestUrl],
|
||||
[route.requestMethod]: true,
|
||||
};
|
||||
return Object.assign({[id]: route}, accRoutes);
|
||||
} else {
|
||||
existedIdSet[route.requestUrl] = {
|
||||
[route.requestMethod]: true,
|
||||
};
|
||||
return Object.assign({[id]: route}, accRoutes);
|
||||
}
|
||||
},
|
||||
{},
|
||||
);
|
||||
return filteredRoutes;
|
||||
}
|
||||
@@ -101,24 +101,6 @@ export type Insights = {
|
||||
retries: RetryInsights | null | undefined;
|
||||
};
|
||||
|
||||
export type Route = {
|
||||
requestUrl: string;
|
||||
requestMethod: string;
|
||||
responseData: string;
|
||||
responseHeaders: {[id: string]: Header};
|
||||
responseStatus: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type MockRoute = {
|
||||
requestUrl: string;
|
||||
method: string;
|
||||
data: string;
|
||||
headers: Header[];
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PartialResponse = {
|
||||
initialResponse?: ResponseInfo;
|
||||
followupChunks: {[id: number]: string};
|
||||
|
||||
@@ -127,3 +127,79 @@ export function getResponseLength(request: ResponseInfo): number {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function formatDuration(duration: number | undefined) {
|
||||
if (typeof duration === 'number') return duration + 'ms';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function formatBytes(count: number | undefined): string {
|
||||
if (typeof count !== 'number') {
|
||||
return '';
|
||||
}
|
||||
if (count > 1024 * 1024) {
|
||||
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
if (count > 1024) {
|
||||
return (count / 1024.0).toFixed(1) + ' kB';
|
||||
}
|
||||
return count + ' B';
|
||||
}
|
||||
|
||||
export function formatStatus(status: number | undefined) {
|
||||
return status ? '' + status : '';
|
||||
}
|
||||
|
||||
export function requestsToText(requests: Request[]): string {
|
||||
const request = requests[0];
|
||||
if (!request || !request.url) {
|
||||
return '<empty request>';
|
||||
}
|
||||
|
||||
let copyText = `# HTTP request for ${request.domain} (ID: ${request.id})
|
||||
## Request
|
||||
HTTP ${request.method} ${request.url}
|
||||
${request.requestHeaders
|
||||
.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
|
||||
// TODO: we want decoding only for non-binary data! See D23403095
|
||||
const requestData = request.requestData
|
||||
? decodeBody({
|
||||
headers: request.requestHeaders,
|
||||
data: request.requestData,
|
||||
})
|
||||
: null;
|
||||
const responseData = request.responseData
|
||||
? decodeBody({
|
||||
headers: request.responseHeaders,
|
||||
data: request.responseData,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (requestData) {
|
||||
copyText += `\n\n${requestData}`;
|
||||
}
|
||||
if (request.status) {
|
||||
copyText += `
|
||||
|
||||
## Response
|
||||
HTTP ${request.status} ${request.reason}
|
||||
${
|
||||
request.responseHeaders
|
||||
?.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n') ?? ''
|
||||
}`;
|
||||
}
|
||||
|
||||
if (responseData) {
|
||||
copyText += `\n\n${responseData}`;
|
||||
}
|
||||
return copyText;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user