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
|
* @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 React from 'react';
|
||||||
|
import {Component} from 'react';
|
||||||
import querystring from 'querystring';
|
import querystring from 'querystring';
|
||||||
import xmlBeautifier from 'xml-beautifier';
|
import xmlBeautifier from 'xml-beautifier';
|
||||||
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
|
||||||
import {Base64} from 'js-base64';
|
import {Base64} from 'js-base64';
|
||||||
|
|
||||||
const WrappingText = styled(Text)({
|
import {
|
||||||
wordWrap: 'break-word',
|
DataInspector,
|
||||||
width: '100%',
|
Layout,
|
||||||
lineHeight: '125%',
|
Panel,
|
||||||
padding: '3px 0',
|
styled,
|
||||||
});
|
theme,
|
||||||
|
CodeBlock,
|
||||||
|
} from 'flipper-plugin';
|
||||||
|
import {Select, Typography} from 'antd';
|
||||||
|
|
||||||
const KeyValueColumnSizes = {
|
import {formatBytes, decodeBody, getHeaderValue} from './utils';
|
||||||
key: '30%',
|
import {Request, Header, Insights, RetryInsights} from './types';
|
||||||
value: 'flex',
|
import {BodyOptions} from './index';
|
||||||
};
|
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
||||||
|
import {KeyValueItem, KeyValueTable} from './KeyValueTable';
|
||||||
|
|
||||||
const KeyValueColumns = {
|
const {Text} = Typography;
|
||||||
key: {
|
|
||||||
value: 'Key',
|
|
||||||
resizable: false,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
value: 'Value',
|
|
||||||
resizable: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type RequestDetailsProps = {
|
type RequestDetailsProps = {
|
||||||
request: Request;
|
request: Request;
|
||||||
@@ -59,52 +37,23 @@ type RequestDetailsProps = {
|
|||||||
onSelectFormat: (bodyFormat: string) => void;
|
onSelectFormat: (bodyFormat: string) => void;
|
||||||
};
|
};
|
||||||
export default class RequestDetails extends Component<RequestDetailsProps> {
|
export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||||
static Container = styled(FlexColumn)({
|
|
||||||
height: '100%',
|
|
||||||
overflow: 'auto',
|
|
||||||
});
|
|
||||||
|
|
||||||
urlColumns = (url: URL) => {
|
urlColumns = (url: URL) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
columns: {
|
key: 'Full URL',
|
||||||
key: {value: <WrappingText>Full URL</WrappingText>},
|
value: url.href,
|
||||||
value: {
|
|
||||||
value: <WrappingText>{url.href}</WrappingText>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copyText: url.href,
|
|
||||||
key: 'url',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
columns: {
|
key: 'Host',
|
||||||
key: {value: <WrappingText>Host</WrappingText>},
|
value: url.host,
|
||||||
value: {
|
|
||||||
value: <WrappingText>{url.host}</WrappingText>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copyText: url.host,
|
|
||||||
key: 'host',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
columns: {
|
key: 'Path',
|
||||||
key: {value: <WrappingText>Path</WrappingText>},
|
value: url.pathname,
|
||||||
value: {
|
|
||||||
value: <WrappingText>{url.pathname}</WrappingText>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copyText: url.pathname,
|
|
||||||
key: 'path',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
columns: {
|
key: 'Query String',
|
||||||
key: {value: <WrappingText>Query String</WrappingText>},
|
value: url.search,
|
||||||
value: {
|
|
||||||
value: <WrappingText>{url.search}</WrappingText>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copyText: url.search,
|
|
||||||
key: 'query',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -113,51 +62,28 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
|||||||
const {request, bodyFormat, onSelectFormat} = this.props;
|
const {request, bodyFormat, onSelectFormat} = this.props;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
const formattedText = bodyFormat == BodyOptions.formatted;
|
const formattedText = bodyFormat == 'formatted';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RequestDetails.Container>
|
<>
|
||||||
<Panel
|
<Panel key="request" title={'Request'}>
|
||||||
key="request"
|
<KeyValueTable items={this.urlColumns(url)} />
|
||||||
heading={'Request'}
|
|
||||||
floating={false}
|
|
||||||
padded={false}>
|
|
||||||
<ManagedTable
|
|
||||||
multiline={true}
|
|
||||||
columnSizes={KeyValueColumnSizes}
|
|
||||||
columns={KeyValueColumns}
|
|
||||||
rows={this.urlColumns(url)}
|
|
||||||
autoHeight={true}
|
|
||||||
floating={false}
|
|
||||||
zebra={false}
|
|
||||||
/>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{url.search ? (
|
{url.search ? (
|
||||||
<Panel
|
<Panel title={'Request Query Parameters'}>
|
||||||
heading={'Request Query Parameters'}
|
|
||||||
floating={false}
|
|
||||||
padded={false}>
|
|
||||||
<QueryInspector queryParams={url.searchParams} />
|
<QueryInspector queryParams={url.searchParams} />
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{request.requestHeaders.length > 0 ? (
|
{request.requestHeaders.length > 0 ? (
|
||||||
<Panel
|
<Panel key="headers" title={'Request Headers'}>
|
||||||
key="headers"
|
|
||||||
heading={'Request Headers'}
|
|
||||||
floating={false}
|
|
||||||
padded={false}>
|
|
||||||
<HeaderInspector headers={request.requestHeaders} />
|
<HeaderInspector headers={request.requestHeaders} />
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{request.requestData != null ? (
|
{request.requestData != null ? (
|
||||||
<Panel
|
<Panel key="requestData" title={'Request Body'} pad>
|
||||||
key="requestData"
|
|
||||||
heading={'Request Body'}
|
|
||||||
floating={false}
|
|
||||||
padded={!formattedText}>
|
|
||||||
<RequestBodyInspector
|
<RequestBodyInspector
|
||||||
formattedText={formattedText}
|
formattedText={formattedText}
|
||||||
request={request}
|
request={request}
|
||||||
@@ -169,21 +95,18 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
|||||||
{request.responseHeaders?.length ? (
|
{request.responseHeaders?.length ? (
|
||||||
<Panel
|
<Panel
|
||||||
key={'responseheaders'}
|
key={'responseheaders'}
|
||||||
heading={`Response Headers${
|
title={`Response Headers${
|
||||||
request.responseIsMock ? ' (Mocked)' : ''
|
request.responseIsMock ? ' (Mocked)' : ''
|
||||||
}`}
|
}`}>
|
||||||
floating={false}
|
|
||||||
padded={false}>
|
|
||||||
<HeaderInspector headers={request.responseHeaders} />
|
<HeaderInspector headers={request.responseHeaders} />
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
<Panel
|
<Panel
|
||||||
key={'responsebody'}
|
key={'responsebody'}
|
||||||
heading={`Response Body${
|
title={`Response Body${
|
||||||
request.responseIsMock ? ' (Mocked)' : ''
|
request.responseIsMock ? ' (Mocked)' : ''
|
||||||
}`}
|
}`}
|
||||||
floating={false}
|
pad>
|
||||||
padded={!formattedText}>
|
|
||||||
<ResponseBodyInspector
|
<ResponseBodyInspector
|
||||||
formattedText={formattedText}
|
formattedText={formattedText}
|
||||||
request={request}
|
request={request}
|
||||||
@@ -191,64 +114,34 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Panel
|
<Panel key="options" title={'Options'} collapsed pad>
|
||||||
key="options"
|
<Text>Body formatting:</Text>
|
||||||
heading={'Options'}
|
|
||||||
floating={false}
|
|
||||||
collapsed={true}>
|
|
||||||
<Select
|
<Select
|
||||||
grow
|
value={bodyFormat}
|
||||||
label="Body"
|
|
||||||
selected={bodyFormat}
|
|
||||||
onChange={onSelectFormat}
|
onChange={onSelectFormat}
|
||||||
options={BodyOptions}
|
options={BodyOptions}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
{request.insights ? (
|
{request.insights ? (
|
||||||
<Panel
|
<Panel key="insights" title={'Insights'} collapsed>
|
||||||
key="insights"
|
|
||||||
heading={'Insights'}
|
|
||||||
floating={false}
|
|
||||||
collapsed={true}>
|
|
||||||
<InsightsInspector insights={request.insights} />
|
<InsightsInspector insights={request.insights} />
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
</RequestDetails.Container>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
||||||
render() {
|
render() {
|
||||||
const {queryParams} = this.props;
|
const rows: KeyValueItem[] = [];
|
||||||
|
this.props.queryParams.forEach((value: string, key: string) => {
|
||||||
const rows: any = [];
|
|
||||||
queryParams.forEach((value: string, key: string) => {
|
|
||||||
rows.push({
|
rows.push({
|
||||||
columns: {
|
key,
|
||||||
key: {
|
value,
|
||||||
value: <WrappingText>{key}</WrappingText>,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
value: <WrappingText>{value}</WrappingText>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copyText: value,
|
|
||||||
key: key,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
|
||||||
return rows.length > 0 ? (
|
|
||||||
<ManagedTable
|
|
||||||
multiline={true}
|
|
||||||
columnSizes={KeyValueColumnSizes}
|
|
||||||
columns={KeyValueColumns}
|
|
||||||
rows={rows}
|
|
||||||
autoHeight={true}
|
|
||||||
floating={false}
|
|
||||||
zebra={false}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,43 +165,15 @@ class HeaderInspector extends Component<
|
|||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows: any = [];
|
const rows = Array.from(computedHeaders.entries())
|
||||||
Array.from(computedHeaders.entries())
|
|
||||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
|
||||||
.forEach(([key, value]) => {
|
.map(([key, value]) => ({key, value}));
|
||||||
rows.push({
|
|
||||||
columns: {
|
|
||||||
key: {
|
|
||||||
value: <WrappingText>{key}</WrappingText>,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
value: <WrappingText>{value}</WrappingText>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copyText: value,
|
|
||||||
key,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows.length > 0 ? (
|
return rows.length > 0 ? (
|
||||||
<ManagedTable
|
<KeyValueTable items={this.props.headers} />
|
||||||
multiline={true}
|
|
||||||
columnSizes={KeyValueColumnSizes}
|
|
||||||
columns={KeyValueColumns}
|
|
||||||
rows={rows}
|
|
||||||
autoHeight={true}
|
|
||||||
floating={false}
|
|
||||||
zebra={false}
|
|
||||||
/>
|
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BodyContainer = styled.div({
|
|
||||||
paddingTop: 10,
|
|
||||||
paddingBottom: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
type BodyFormatter = {
|
type BodyFormatter = {
|
||||||
formatRequest?: (request: Request) => any;
|
formatRequest?: (request: Request) => any;
|
||||||
formatResponse?: (request: Request) => any;
|
formatResponse?: (request: Request) => any;
|
||||||
@@ -330,12 +195,12 @@ class RequestBodyInspector extends Component<{
|
|||||||
const component = formatter.formatRequest(request);
|
const component = formatter.formatRequest(request);
|
||||||
if (component) {
|
if (component) {
|
||||||
return (
|
return (
|
||||||
<BodyContainer>
|
<Layout.Container gap>
|
||||||
{component}
|
{component}
|
||||||
<FormattedBy>
|
<FormattedBy>
|
||||||
Formatted by {formatter.constructor.name}
|
Formatted by {formatter.constructor.name}
|
||||||
</FormattedBy>
|
</FormattedBy>
|
||||||
</BodyContainer>
|
</Layout.Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -366,12 +231,12 @@ class ResponseBodyInspector extends Component<{
|
|||||||
const component = formatter.formatResponse(request);
|
const component = formatter.formatResponse(request);
|
||||||
if (component) {
|
if (component) {
|
||||||
return (
|
return (
|
||||||
<BodyContainer>
|
<Layout.Container gap>
|
||||||
{component}
|
{component}
|
||||||
<FormattedBy>
|
<FormattedBy>
|
||||||
Formatted by {formatter.constructor.name}
|
Formatted by {formatter.constructor.name}
|
||||||
</FormattedBy>
|
</FormattedBy>
|
||||||
</BodyContainer>
|
</Layout.Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -386,17 +251,18 @@ class ResponseBodyInspector extends Component<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormattedBy = styled(SmallText)({
|
const FormattedBy = styled(Text)({
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
fontSize: '0.7em',
|
fontSize: '0.7em',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
|
color: theme.disabledColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Empty = () => (
|
const Empty = () => (
|
||||||
<BodyContainer>
|
<Layout.Container pad>
|
||||||
<Text>(empty)</Text>
|
<Text>(empty)</Text>
|
||||||
</BodyContainer>
|
</Layout.Container>
|
||||||
);
|
);
|
||||||
|
|
||||||
function getRequestData(request: Request) {
|
function getRequestData(request: Request) {
|
||||||
@@ -420,29 +286,19 @@ function renderRawBody(request: Request, mode: 'request' | 'response') {
|
|||||||
mode === 'request' ? getRequestData(request) : getResponseData(request),
|
mode === 'request' ? getRequestData(request) : getResponseData(request),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<BodyContainer>
|
<Layout.Container gap>
|
||||||
{decoded ? (
|
{decoded ? (
|
||||||
<Text selectable wordWrap="break-word">
|
<CodeBlock>{decoded}</CodeBlock>
|
||||||
{decoded}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FormattedBy>(Failed to decode)</FormattedBy>
|
<FormattedBy>(Failed to decode)</FormattedBy>
|
||||||
<Text selectable wordWrap="break-word">
|
<CodeBlock>{data}</CodeBlock>
|
||||||
{data}
|
|
||||||
</Text>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</BodyContainer>
|
</Layout.Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaContainer = styled(FlexColumn)({
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
});
|
|
||||||
|
|
||||||
type ImageWithSizeProps = {
|
type ImageWithSizeProps = {
|
||||||
src: string;
|
src: string;
|
||||||
};
|
};
|
||||||
@@ -459,13 +315,8 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Text = styled(Text)({
|
constructor(props: ImageWithSizeProps) {
|
||||||
color: colors.dark70,
|
super(props);
|
||||||
fontSize: 14,
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor(props: ImageWithSizeProps, context: any) {
|
|
||||||
super(props, context);
|
|
||||||
this.state = {
|
this.state = {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
@@ -487,12 +338,12 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<MediaContainer>
|
<Layout.Container center>
|
||||||
<ImageWithSize.Image src={this.props.src} />
|
<ImageWithSize.Image src={this.props.src} />
|
||||||
<ImageWithSize.Text>
|
<Text type="secondary">
|
||||||
{this.state.width} x {this.state.height}
|
{this.state.width} x {this.state.height}
|
||||||
</ImageWithSize.Text>
|
</Text>
|
||||||
</MediaContainer>
|
</Layout.Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,44 +379,36 @@ class VideoFormatter {
|
|||||||
const contentType = getHeaderValue(request.responseHeaders, 'content-type');
|
const contentType = getHeaderValue(request.responseHeaders, 'content-type');
|
||||||
if (contentType.startsWith('video/')) {
|
if (contentType.startsWith('video/')) {
|
||||||
return (
|
return (
|
||||||
<MediaContainer>
|
<Layout.Container center>
|
||||||
<VideoFormatter.Video controls={true}>
|
<VideoFormatter.Video controls={true}>
|
||||||
<source src={request.url} type={contentType} />
|
<source src={request.url} type={contentType} />
|
||||||
</VideoFormatter.Video>
|
</VideoFormatter.Video>
|
||||||
</MediaContainer>
|
</Layout.Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class JSONText extends Component<{children: any}> {
|
class JSONText extends Component<{children: any}> {
|
||||||
static NoScrollbarText = styled(Text)({
|
|
||||||
overflowY: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const jsonObject = this.props.children;
|
const jsonObject = this.props.children;
|
||||||
return (
|
return (
|
||||||
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
|
<CodeBlock>
|
||||||
{JSON.stringify(jsonObject, null, 2)}
|
{JSON.stringify(jsonObject, null, 2)}
|
||||||
{'\n'}
|
{'\n'}
|
||||||
</JSONText.NoScrollbarText>
|
</CodeBlock>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class XMLText extends Component<{body: any}> {
|
class XMLText extends Component<{body: any}> {
|
||||||
static NoScrollbarText = styled(Text)({
|
|
||||||
overflowY: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const xmlPretty = xmlBeautifier(this.props.body);
|
const xmlPretty = xmlBeautifier(this.props.body);
|
||||||
return (
|
return (
|
||||||
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
|
<CodeBlock>
|
||||||
{xmlPretty}
|
{xmlPretty}
|
||||||
{'\n'}
|
{'\n'}
|
||||||
</XMLText.NoScrollbarText>
|
</CodeBlock>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,20 +495,14 @@ class JSONFormatter {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
return (
|
return <DataInspector collapsed expandRoot data={data} />;
|
||||||
<ManagedDataInspector
|
|
||||||
collapsed={true}
|
|
||||||
expandRoot={true}
|
|
||||||
data={data}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} catch (SyntaxError) {
|
} catch (SyntaxError) {
|
||||||
// Multiple top level JSON roots, map them one by one
|
// Multiple top level JSON roots, map them one by one
|
||||||
const roots = body.split('\n');
|
const roots = body.split('\n');
|
||||||
return (
|
return (
|
||||||
<ManagedDataInspector
|
<DataInspector
|
||||||
collapsed={true}
|
collapsed
|
||||||
expandRoot={true}
|
expandRoot
|
||||||
data={roots.map((json) => JSON.parse(json))}
|
data={roots.map((json) => JSON.parse(json))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -681,7 +518,7 @@ class LogEventFormatter {
|
|||||||
if (typeof data.message === 'string') {
|
if (typeof data.message === 'string') {
|
||||||
data.message = JSON.parse(data.message);
|
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') {
|
if (typeof data.queries === 'string') {
|
||||||
data.queries = JSON.parse(data.queries);
|
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 requestStartMs = serverMetadata['request_start_time_ms'];
|
||||||
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
|
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
|
||||||
return (
|
return (
|
||||||
<WrappingText>
|
<Text>
|
||||||
{'Server wall time for initial response (ms): ' +
|
{'Server wall time for initial response (ms): ' +
|
||||||
(timeAtFlushMs - requestStartMs)}
|
(timeAtFlushMs - requestStartMs)}
|
||||||
</WrappingText>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
formatRequest(request: Request) {
|
formatRequest(request: Request) {
|
||||||
@@ -736,7 +573,7 @@ class GraphQLFormatter {
|
|||||||
if (typeof data.query_params === 'string') {
|
if (typeof data.query_params === 'string') {
|
||||||
data.query_params = JSON.parse(data.query_params);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{this.parsedServerTimeForFirstFlush(data)}
|
{this.parsedServerTimeForFirstFlush(data)}
|
||||||
<ManagedDataInspector
|
<DataInspector collapsed expandRoot data={data} />
|
||||||
collapsed={true}
|
|
||||||
expandRoot={true}
|
|
||||||
data={data}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} catch (SyntaxError) {
|
} catch (SyntaxError) {
|
||||||
@@ -776,11 +609,7 @@ class GraphQLFormatter {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{this.parsedServerTimeForFirstFlush(parsedResponses)}
|
{this.parsedServerTimeForFirstFlush(parsedResponses)}
|
||||||
<ManagedDataInspector
|
<DataInspector collapsed expandRoot data={parsedResponses} />
|
||||||
collapsed={true}
|
|
||||||
expandRoot={true}
|
|
||||||
data={parsedResponses}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -796,12 +625,7 @@ class FormUrlencodedFormatter {
|
|||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return (
|
return <DataInspector expandRoot data={querystring.parse(decoded)} />;
|
||||||
<ManagedDataInspector
|
|
||||||
expandRoot={true}
|
|
||||||
data={querystring.parse(decoded)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -921,13 +745,13 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
|||||||
return `${formatBytes(value)}/sec`;
|
return `${formatBytes(value)}/sec`;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatRetries(retry: RetryInsights): string {
|
formatRetries = (retry: RetryInsights): string => {
|
||||||
const timesWord = retry.limit === 1 ? 'time' : 'times';
|
const timesWord = retry.limit === 1 ? 'time' : 'times';
|
||||||
|
|
||||||
return `${this.formatTime(retry.timeSpent)} (${
|
return `${this.formatTime(retry.timeSpent)} (${
|
||||||
retry.count
|
retry.count
|
||||||
} ${timesWord} out of ${retry.limit})`;
|
} ${timesWord} out of ${retry.limit})`;
|
||||||
}
|
};
|
||||||
|
|
||||||
buildRow<T>(
|
buildRow<T>(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -936,16 +760,8 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
|||||||
): any {
|
): any {
|
||||||
return value
|
return value
|
||||||
? {
|
? {
|
||||||
columns: {
|
|
||||||
key: {
|
|
||||||
value: <WrappingText>{name}</WrappingText>,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
value: <WrappingText>{formatter(value)}</WrappingText>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copyText: () => `${name}: ${formatter(value)}`,
|
|
||||||
key: name,
|
key: name,
|
||||||
|
value: formatter(value),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -955,7 +771,7 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
|||||||
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
|
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
buildRow('Retries', insights.retries, formatRetries.bind(this)),
|
buildRow('Retries', insights.retries, formatRetries),
|
||||||
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
|
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
|
||||||
buildRow('Connect time', insights.connectTime, formatTime),
|
buildRow('Connect time', insights.connectTime, formatTime),
|
||||||
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
|
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
|
||||||
@@ -968,16 +784,6 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
|||||||
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
|
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
|
||||||
].filter((r) => r != null);
|
].filter((r) => r != null);
|
||||||
|
|
||||||
return rows.length > 0 ? (
|
return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
|
||||||
<ManagedTable
|
|
||||||
multiline={true}
|
|
||||||
columnSizes={KeyValueColumnSizes}
|
|
||||||
columns={KeyValueColumns}
|
|
||||||
rows={rows}
|
|
||||||
autoHeight={true}
|
|
||||||
floating={false}
|
|
||||||
zebra={false}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,42 +7,12 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {createContext, createRef} from 'react';
|
import React, {createRef} from 'react';
|
||||||
import {Menu, message} from 'antd';
|
import {Button, Menu, Modal, Typography} from 'antd';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
Button,
|
|
||||||
Glyph,
|
|
||||||
colors,
|
|
||||||
DetailSidebar,
|
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,
|
PluginClient,
|
||||||
Device,
|
Device,
|
||||||
createState,
|
createState,
|
||||||
@@ -52,20 +22,49 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTableManager,
|
DataTableManager,
|
||||||
|
theme,
|
||||||
} from 'flipper-plugin';
|
} from 'flipper-plugin';
|
||||||
import fs from 'fs';
|
import {
|
||||||
// eslint-disable-next-line
|
Request,
|
||||||
import electron, {OpenDialogOptions, remote} from 'electron';
|
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 {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_MOCK_ROUTE_LIST_KEY = '__NETWORK_CACHED_MOCK_ROUTE_LIST';
|
||||||
const LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY =
|
const LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY =
|
||||||
'__NETWORK_CACHED_RESPONSE_BODY_FORMAT';
|
'__NETWORK_CACHED_RESPONSE_BODY_FORMAT';
|
||||||
|
|
||||||
export const BodyOptions = {
|
export const BodyOptions = ['formatted', 'parsed'].map((value) => ({
|
||||||
formatted: 'formatted',
|
label: value,
|
||||||
parsed: 'parsed',
|
value,
|
||||||
};
|
}));
|
||||||
|
|
||||||
type Events = {
|
type Events = {
|
||||||
newRequest: RequestInfo;
|
newRequest: RequestInfo;
|
||||||
@@ -78,58 +77,6 @@ type Methods = {
|
|||||||
mockResponses(params: {routes: MockRoute[]}): Promise<void>;
|
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>) {
|
export function plugin(client: PluginClient<Events, Methods>) {
|
||||||
const networkRouteManager = createState<NetworkRouteManager>(
|
const networkRouteManager = createState<NetworkRouteManager>(
|
||||||
nullNetworkRouteManager,
|
nullNetworkRouteManager,
|
||||||
@@ -137,11 +84,12 @@ export function plugin(client: PluginClient<Events, Methods>) {
|
|||||||
|
|
||||||
const routes = createState<{[id: string]: Route}>({});
|
const routes = createState<{[id: string]: Route}>({});
|
||||||
const nextRouteId = createState<number>(0);
|
const nextRouteId = createState<number>(0);
|
||||||
const isMockResponseSupported = createState<boolean>(false);
|
const isMockResponseSupported = createState<boolean>(false, {
|
||||||
|
persist: 'isMockResponseSupported',
|
||||||
|
});
|
||||||
const showMockResponseDialog = createState<boolean>(false);
|
const showMockResponseDialog = createState<boolean>(false);
|
||||||
const detailBodyFormat = createState<string>(
|
const detailBodyFormat = createState<string>(
|
||||||
localStorage.getItem(LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY) ||
|
localStorage.getItem(LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY) || 'parsed',
|
||||||
BodyOptions.parsed,
|
|
||||||
);
|
);
|
||||||
const requests = createDataSource<Request, 'id'>([], {
|
const requests = createDataSource<Request, 'id'>([], {
|
||||||
key: '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) {
|
if (device.isArchived) {
|
||||||
return Promise.resolve(true);
|
return isMockResponseSupported.get();
|
||||||
} else {
|
} else {
|
||||||
return client.supportsMethod('mockResponses');
|
return client.supportsMethod('mockResponses');
|
||||||
}
|
}
|
||||||
@@ -273,157 +221,14 @@ export function plugin(client: PluginClient<Events, Methods>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// declare new variable to be called inside the interface
|
// declare new variable to be called inside the interface
|
||||||
networkRouteManager.set({
|
networkRouteManager.set(
|
||||||
addRoute(): string | null {
|
createNetworkManager(
|
||||||
const newNextRouteId = nextRouteId.get();
|
nextRouteId,
|
||||||
routes.update((draft) => {
|
routes,
|
||||||
draft[newNextRouteId.toString()] = {
|
informClientMockChange,
|
||||||
requestUrl: '',
|
tableManagerRef,
|
||||||
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());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLogs() {
|
function clearLogs() {
|
||||||
@@ -431,29 +236,7 @@ export function plugin(client: PluginClient<Events, Methods>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function informClientMockChange(routes: {[id: string]: Route}) {
|
async function informClientMockChange(routes: {[id: string]: Route}) {
|
||||||
const existedIdSet: {[id: string]: {[method: string]: boolean}} = {};
|
const filteredRoutes: {[id: string]: Route} = computeMockRoutes(routes);
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isMockResponseSupported.get()) {
|
if (isMockResponseSupported.get()) {
|
||||||
const routesValuesArray = Object.values(filteredRoutes);
|
const routesValuesArray = Object.values(filteredRoutes);
|
||||||
@@ -584,7 +367,7 @@ export function Component() {
|
|||||||
onRowStyle={getRowStyle}
|
onRowStyle={getRowStyle}
|
||||||
tableManagerRef={instance.tableManagerRef}
|
tableManagerRef={instance.tableManagerRef}
|
||||||
onSelect={instance.onSelect}
|
onSelect={instance.onSelect}
|
||||||
onCopyRows={copyRow}
|
onCopyRows={requestsToText}
|
||||||
onContextMenu={instance.onContextMenu}
|
onContextMenu={instance.onContextMenu}
|
||||||
enableAutoScroll
|
enableAutoScroll
|
||||||
extraActions={
|
extraActions={
|
||||||
@@ -598,33 +381,46 @@ export function Component() {
|
|||||||
</Layout.Horizontal>
|
</Layout.Horizontal>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{showMockResponseDialog ? (
|
<Modal
|
||||||
<Sheet>
|
visible={showMockResponseDialog}
|
||||||
{(onHide) => (
|
onCancel={instance.onCloseButtonPressed}
|
||||||
<MockResponseDialog
|
footer={null}
|
||||||
routes={routes}
|
title="Mock Network Responses"
|
||||||
onHide={() => {
|
width={1200}>
|
||||||
onHide();
|
<ManageMockResponsePanel routes={routes} />
|
||||||
instance.onCloseButtonPressed();
|
</Modal>
|
||||||
}}
|
<DetailSidebar width={400}>
|
||||||
highlightedRows={
|
|
||||||
new Set(
|
|
||||||
instance.tableManagerRef
|
|
||||||
.current!.getSelectedItems()
|
|
||||||
.map((r) => r.id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
requests={instance.requests}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Sheet>
|
|
||||||
) : null}
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
</DetailSidebar>
|
||||||
</Layout.Container>
|
</Layout.Container>
|
||||||
</NetworkRouteContext.Provider>
|
</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>[] = [
|
const columns: DataTableColumn<Request>[] = [
|
||||||
{
|
{
|
||||||
key: 'requestTime',
|
key: 'requestTime',
|
||||||
@@ -673,6 +469,14 @@ const columns: DataTableColumn<Request>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mockingStyle = {
|
||||||
|
color: theme.warningColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorStyle = {
|
||||||
|
color: theme.errorColor,
|
||||||
|
};
|
||||||
|
|
||||||
function getRowStyle(row: Request) {
|
function getRowStyle(row: Request) {
|
||||||
return row.responseIsMock
|
return row.responseIsMock
|
||||||
? mockingStyle
|
? mockingStyle
|
||||||
@@ -680,101 +484,3 @@ function getRowStyle(row: Request) {
|
|||||||
? errorStyle
|
? errorStyle
|
||||||
: undefined;
|
: 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;
|
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 = {
|
export type PartialResponse = {
|
||||||
initialResponse?: ResponseInfo;
|
initialResponse?: ResponseInfo;
|
||||||
followupChunks: {[id: number]: string};
|
followupChunks: {[id: number]: string};
|
||||||
|
|||||||
@@ -127,3 +127,79 @@ export function getResponseLength(request: ResponseInfo): number {
|
|||||||
}
|
}
|
||||||
return 0;
|
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