diff --git a/README.md b/README.md index 615de5c..659704b 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,27 @@ import NetworkLogger from 'react-native-network-logger'; const MyScreen = () => ; ``` +#### Initial Expansion Controls of RequestDetails component + +Control which sections of the RequestDetails component are expanded by default when viewing a request: + +```tsx +import NetworkLogger from 'react-native-network-logger'; + +const MyScreen = () => ( + +); +``` + +These affect the initial expansion of the Request/Response headers and body sections in the details view. + #### Force Enable If you are running another network logging interceptor, e.g. Reactotron, the logger will not start as only one can be run at once. You can override this behaviour and force the logger to start by using the `forceEnable` option. diff --git a/src/components/BodyViewer.tsx b/src/components/BodyViewer.tsx new file mode 100644 index 0000000..20acde3 --- /dev/null +++ b/src/components/BodyViewer.tsx @@ -0,0 +1,276 @@ +import React, { useState, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + Platform, + TouchableOpacity, + TextInput, +} from 'react-native'; +import { useThemedStyles, Theme } from '../theme'; + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type BodyViewerProps = { + content?: string; + data?: JsonValue; + initiallyExpanded?: boolean; +}; + +const BodyViewer: React.FC = ({ + content, + data, + initiallyExpanded = true, +}) => { + const parsed = useMemo(() => { + if (data !== undefined) return { isJson: true, value: data } as const; + return parseIfJson(content || ''); + }, [content, data]); + + if (parsed.isJson && parsed.value !== null) { + return ( + + ); + } + + return {content || ''}; +}; + +export default BodyViewer; + +const isObject = (v: JsonValue): v is { [key: string]: JsonValue } => + !!v && typeof v === 'object' && !Array.isArray(v); + +const isArray = (v: JsonValue): v is JsonValue[] => Array.isArray(v); + +const stringifyPrimitive = (value: JsonPrimitive) => { + if (typeof value === 'string') return `"${value}"`; + if (value === null) return 'null'; + return String(value); +}; + +const ExpandRow: React.FC<{ + name?: string | number; + level: number; + open: string; + close: string; + expanded: boolean; + hasChildren: boolean; + childrenCount: number; + onToggle: () => void; + children?: React.ReactNode; +}> = ({ + name, + level, + open, + close, + expanded, + hasChildren, + childrenCount, + onToggle, + children, +}) => { + const styles = useThemedStyles(themedStyles); + return ( + + + + {hasChildren ? (expanded ? '▼ ' : '▶ ') : ''} + {name !== undefined ? `${String(name)}: ` : ''} + {open} + {!expanded ? `${hasChildren ? childrenCount : ''}` : ''} + {!expanded ? ( + {close} + ) : null} + + + {expanded && ( + <> + {children} + + {close} + + + )} + + ); +}; + +const JsonNode: React.FC<{ + name?: string | number; + value: JsonValue; + level: number; + initiallyExpanded?: boolean; +}> = ({ name, value, level, initiallyExpanded = false }) => { + const styles = useThemedStyles(themedStyles); + const [expanded, setExpanded] = useState(initiallyExpanded); + + if (isObject(value)) { + const entries = Object.entries(value); + const open = '{'; + const close = '}'; + const hasLength = entries.length > 0; + return ( + setExpanded((e) => !e)} + > + {entries.map(([k, v]) => ( + + ))} + + ); + } + + if (isArray(value)) { + const open = '['; + const close = ']'; + const hasLength = value?.length > 0; + return ( + setExpanded((e) => !e)} + > + {value.map((v, idx) => ( + + ))} + + ); + } + + return ( + + + {name !== undefined ? `${String(name)}: ` : ''} + {stringifyPrimitive(value as JsonPrimitive)} + + + ); +}; + +const CollapsibleJsonView: React.FC<{ + data: JsonValue; + initiallyExpanded?: boolean; +}> = ({ data, initiallyExpanded = true }) => { + const styles = useThemedStyles(themedStyles); + return ( + + + + + + ); +}; + +const parseIfJson = ( + text: string +): { isJson: boolean; value: JsonValue | null } => { + try { + const obj = JSON.parse(text); + return { isJson: true, value: obj }; + } catch { + return { isJson: false, value: null }; + } +}; + +const TextViewer: React.FC<{ children: string }> = ({ children }) => { + const styles = useThemedStyles(themedStyles); + + if (Platform.OS === 'ios') { + /** + * A readonly TextInput is used because large Text blocks sometimes don't render on iOS + * See this issue https://github.com/facebook/react-native/issues/19453 + * Note: Even with the fix mentioned in the comments, text with ~10,000 lines still fails to render + */ + return ( + + ); + } + + return ( + + + + {children} + + + + ); +}; + +const themedStyles = (theme: Theme) => + StyleSheet.create({ + baseText: { + fontFamily: Platform.select({ + ios: 'Menlo', + android: 'monospace', + }), + color: theme.colors.text, + }, + content: { + padding: 10, + color: theme.colors.text, + backgroundColor: theme.colors.card, + }, + textAreaContainer: { + maxHeight: 350, + }, + jsonRow: { + paddingVertical: 4, + }, + jsonText: { + fontSize: 14, + }, + jsonBracket: { + paddingStart: 4, + color: theme.colors.text, + }, + jsonLevelContainerBase: { + borderRadius: 4, + marginVertical: 2, + paddingBottom: 2, + }, + jsonLevelEven: { + backgroundColor: `${theme.colors.card}ff`, + }, + jsonLevelOdd: { + backgroundColor: `${theme.colors.background}44`, + }, + }); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b7f4a75..35b7951 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,24 +1,41 @@ import React from 'react'; -import { View, Text, StyleSheet, Share } from 'react-native'; +import { View, Text, StyleSheet, Share, TouchableOpacity } from 'react-native'; import { useThemedStyles, Theme } from '../theme'; import Icon from './Icon'; interface Props { children: string; shareContent?: string; + collapsible?: boolean; + expanded?: boolean; + onToggle?: () => void; } -const Header: React.FC = ({ children, shareContent }) => { +const Header: React.FC = ({ + children, + shareContent, + collapsible, + expanded = false, + onToggle, +}) => { const styles = useThemedStyles(themedStyles); + const prefix = collapsible ? (expanded ? '▼ ' : '▶ ') : ''; return ( - - {children} - + + {prefix + children} + + {!!shareContent && ( StyleSheet.create({ header: { fontWeight: 'bold', - fontSize: 20, + fontSize: 16, marginTop: 10, marginBottom: 5, marginHorizontal: 10, diff --git a/src/components/NetworkLogger.tsx b/src/components/NetworkLogger.tsx index a5f237b..a9b7e1e 100644 --- a/src/components/NetworkLogger.tsx +++ b/src/components/NetworkLogger.tsx @@ -16,6 +16,10 @@ interface Props { sort?: 'asc' | 'desc'; compact?: boolean; maxRows?: number; + initialRequestHeadersExpanded?: boolean; + initialResponseHeadersExpanded?: boolean; + initialRequestBodyExpanded?: boolean; + initialResponseBodyExpanded?: boolean; } const sortRequests = (requests: NetworkRequestInfo[], sort: 'asc' | 'desc') => { @@ -30,6 +34,10 @@ const NetworkLogger: React.FC = ({ sort = 'desc', compact = false, maxRows, + initialRequestHeadersExpanded, + initialResponseHeadersExpanded, + initialRequestBodyExpanded, + initialResponseBodyExpanded, }) => { const [requests, setRequests] = useState(logger.getRequests()); const [request, setRequest] = useState(); @@ -122,8 +130,13 @@ const NetworkLogger: React.FC = ({ {showDetails && !!request && ( setShowDetails(false)} request={request} + initialRequestHeadersExpanded={initialRequestHeadersExpanded} + initialResponseHeadersExpanded={initialResponseHeadersExpanded} + initialRequestBodyExpanded={initialRequestBodyExpanded} + initialResponseBodyExpanded={initialResponseBodyExpanded} /> )} diff --git a/src/components/RequestDetails.tsx b/src/components/RequestDetails.tsx index be177bc..0a5ebf9 100644 --- a/src/components/RequestDetails.tsx +++ b/src/components/RequestDetails.tsx @@ -5,7 +5,6 @@ import { StyleSheet, ScrollView, Share, - TextInput, Platform, } from 'react-native'; import NetworkRequestInfo from '../NetworkRequestInfo'; @@ -14,72 +13,76 @@ import { backHandlerSet } from '../backHandler'; import ResultItem from './ResultItem'; import Header from './Header'; import Button from './Button'; +import BodyViewer from './BodyViewer'; interface Props { request: NetworkRequestInfo; onClose(): void; + compact?: boolean; + initialRequestHeadersExpanded?: boolean; + initialResponseHeadersExpanded?: boolean; + initialRequestBodyExpanded?: boolean; + initialResponseBodyExpanded?: boolean; } const Headers = ({ title = 'Headers', - headers, + headers = {}, + initiallyExpanded = true, }: { title: string; headers?: object; + initiallyExpanded?: boolean; }) => { const styles = useThemedStyles(themedStyles); + const [expanded, setExpanded] = useState(initiallyExpanded); return ( -
+
setExpanded((e) => !e)} + shareContent={headers && JSON.stringify(headers, null, 2)} + > {title}
- - {Object.entries(headers || {}).map(([name, value]) => ( - - {name}: - {value} - - ))} - - - ); -}; - -const LargeText: React.FC<{ children: string }> = ({ children }) => { - const styles = useThemedStyles(themedStyles); - - if (Platform.OS === 'ios') { - /** - * A readonly TextInput is used because large Text blocks sometimes don't render on iOS - * See this issue https://github.com/facebook/react-native/issues/19453 - * Note: Even with the fix mentioned in the comments, text with ~10,000 lines still fails to render - */ - return ( - - ); - } - - return ( - - - - - {children} - + {expanded && ( + + {Object.entries(headers).map(([name, value], index) => ( + + {name}: + {value} + + ))} - + )} ); }; -const RequestDetails: React.FC = ({ request, onClose }) => { +const RequestDetails: React.FC = ({ + request, + onClose, + compact = false, + initialRequestHeadersExpanded = true, + initialResponseHeadersExpanded = true, + initialRequestBodyExpanded = true, + initialResponseBodyExpanded = true, +}) => { const [responseBody, setResponseBody] = useState('Loading...'); const styles = useThemedStyles(themedStyles); + const [requestBodyExpanded, setRequestBodyExpanded] = useState( + initialRequestBodyExpanded + ); + const [responseBodyExpanded, setResponseBodyExpanded] = useState( + initialResponseBodyExpanded + ); useEffect(() => { (async () => { @@ -109,27 +112,60 @@ const RequestDetails: React.FC = ({ request, onClose }) => { return ( - + - -
Request Body
- {requestBody} - -
Response Body
- {responseBody} -
More
- -
+ {requestBodyExpanded && ( + + )} + +
setResponseBodyExpanded((e) => !e)} > - Share as cURL - + Response Body +
+ {responseBodyExpanded && ( + + )} + + + + {!backHandlerSet() && (