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:
Michel Weststrate
2021-05-06 04:26:41 -07:00
committed by Facebook GitHub Bot
parent 84d65b1a77
commit fc4a08eb55
11 changed files with 977 additions and 1436 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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;
}