diff --git a/modules/navigation-buttons/index.ts b/modules/navigation-buttons/index.ts index 722e06e0c5..83da2353eb 100644 --- a/modules/navigation-buttons/index.ts +++ b/modules/navigation-buttons/index.ts @@ -4,3 +4,4 @@ export {OpenSettingsButton} from './open-settings' export {FavoriteButton} from './favorite' export {DebugNoticeButton} from './debug' export {SearchButton} from './search' +export {NetworkLoggerButton} from './network-logger' diff --git a/modules/navigation-buttons/network-logger.tsx b/modules/navigation-buttons/network-logger.tsx new file mode 100644 index 0000000000..2655def2f0 --- /dev/null +++ b/modules/navigation-buttons/network-logger.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import {Platform, StyleSheet, Text} from 'react-native' +import {Touchable} from '@frogpond/touchable' +import {useNavigation, useTheme} from '@react-navigation/native' +import {commonStyles, rightButtonStyles as styles} from './styles' + +export const buttonStyles = StyleSheet.create({ + text: { + ...Platform.select({ + ios: { + fontWeight: '600', + }, + android: { + fontWeight: '400', + }, + }), + }, +}) + +export const NetworkLoggerButton: React.FC = () => { + const navigation = useNavigation() + let {colors} = useTheme() + + return ( + navigation.navigate('NetworkLogger')} + style={styles.button} + > + + Log + + + ) +} diff --git a/modules/use-debounce/index.ts b/modules/use-debounce/index.ts index 2502d09874..e66a6d0713 100644 --- a/modules/use-debounce/index.ts +++ b/modules/use-debounce/index.ts @@ -1,3 +1,4 @@ +import {debounce} from 'lodash' import {useState, useEffect} from 'react' export function useDebounce(value: T, delay: number): T { @@ -15,3 +16,12 @@ export function useDebounce(value: T, delay: number): T { return debouncedValue } + +export const debounceSearch = debounce( + (query: string, callback: () => void) => { + if (query.length >= 2) { + callback() + } + }, + 1500, +) diff --git a/modules/use-debounce/package.json b/modules/use-debounce/package.json index 75f58bea37..00129e6cd3 100644 --- a/modules/use-debounce/package.json +++ b/modules/use-debounce/package.json @@ -8,6 +8,9 @@ "scripts": { "test": "jest" }, + "dependencies": { + "lodash": "4.17.21" + }, "peerDependencies": { "react": "^18.0.0" } diff --git a/package-lock.json b/package-lock.json index e6ed57b2ee..8c296813c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -576,6 +576,9 @@ "name": "@frogpond/use-debounce", "version": "1.0.0", "license": "ISC", + "dependencies": { + "lodash": "4.17.21" + }, "peerDependencies": { "react": "^18.0.0" } diff --git a/source/navigation/routes.tsx b/source/navigation/routes.tsx index 250d668654..e0c0f2400b 100644 --- a/source/navigation/routes.tsx +++ b/source/navigation/routes.tsx @@ -294,6 +294,11 @@ const SettingsStackScreens = () => { name="APITest" options={settings.APITestNavigationOptions} /> + { - let [searchPath, setSearchPath] = React.useState('') - let path = useDebounce(searchPath.trim().toLowerCase(), 500) - - let [displayMode, setDisplayMode] = React.useState('raw') - - let {data, isLoading, error} = useQuery({ - queryKey: ['api-test', path], - queryFn: ({signal, queryKey: [_group, path]}) => { - if (!path) { - return '' - } - return client.get(path, {signal, cache: 'no-store'}).text() - }, - staleTime: 0, - cacheTime: 0, - }) - - let navigation = useNavigation() - - const HeaderLeftButton = () => ( - setSearchPath('')} - style={commonStyles.button} - > - Reset - - ) - - React.useLayoutEffect(() => { - navigation.setOptions({ - headerLeft: () => Platform.OS === 'ios' && , - headerSearchBarOptions: { - autoCapitalize: 'none', - barTintColor: c.systemFill, - // android-only - autoFocus: true, - hideNavigationBar: false, - hideWhenScrolling: false, - onChangeText: (event: ChangeTextEvent) => - setSearchPath(event.nativeEvent.text), - placeholder: 'path/to/uri', - }, - }) - }, [navigation]) - - const JSONView = (): JSX.Element => { - if (data === undefined) { - return <> - } - - const parsed = JSON.parse(data ?? '') - const formatted = JSON.stringify(parsed, null, 2) - const highlighted = syntaxHighlight(formatted) - - const HTML_CONTENT = ` - ${CSS_CODE_STYLES} -
${highlighted}
- ` - - return ( - - ) - } - - const EmptySearch = () => { - return ( - - - Search for an API Endpoint - - ) - } - - let APIResponse = - error !== null ? ( - - ) : !path ? ( - - ) : isLoading ? ( - - ) : displayMode === 'raw' ? ( - - ) : ( - - ) - - const SearchResponseView = () => ( - - setDisplayMode(val ? 'parsed' : 'raw')} - value={displayMode === 'parsed'} - /> - - {APIResponse} - - ) - - return path.length ? ( - - ) : ( - - ) -} - -export const NavigationOptions: NativeStackNavigationOptions = { - title: 'API Tester', - presentation: 'modal', - headerRight: () => Platform.OS === 'ios' && , - gestureEnabled: Platform.OS === 'ios' && false, -} diff --git a/source/views/settings/screens/api-test/detail.tsx b/source/views/settings/screens/api-test/detail.tsx new file mode 100644 index 0000000000..a449e3c452 --- /dev/null +++ b/source/views/settings/screens/api-test/detail.tsx @@ -0,0 +1,125 @@ +import React from 'react' +import {View, StyleSheet, Platform, TextInput} from 'react-native' + +import {LoadingView, NoticeView} from '@frogpond/notice' +import * as c from '@frogpond/colors' + +import {RouteProp, useNavigation, useRoute} from '@react-navigation/native' +import {NativeStackNavigationOptions} from '@react-navigation/native-stack' +import {SettingsStackParamList} from '../../../../navigation/types' +import {useQuery} from '@tanstack/react-query' +import {client} from '@frogpond/api' +import {iOSUIKit, material} from 'react-native-typography' +import {HtmlContent} from '@frogpond/html-content' +import {CellToggle} from '@frogpond/tableview/cells' +import {NetworkLoggerButton} from '@frogpond/navigation-buttons' +import {CSS_CODE_STYLES} from './util/highlight-styles' +import {syntaxHighlight} from './util/highlight' +import {DebugView} from '../debug' + +type DisplayMode = 'raw' | 'parsed' + +export const APITestDetailView = (): JSX.Element => { + let navigation = useNavigation() + let route = useRoute>() + + const {displayName} = route.params.query + const cleanedName = displayName.trim().toLowerCase() + let [displayMode, setDisplayMode] = React.useState('raw') + + let {data, isLoading, error} = useQuery({ + queryKey: ['api-test', cleanedName], + queryFn: ({signal, queryKey: [_group]}) => { + if (!cleanedName) { + return '' + } + return client.get(cleanedName, {signal, cache: 'no-store'}).text() + }, + staleTime: 0, + cacheTime: 0, + }) + + React.useLayoutEffect(() => { + navigation.setOptions({ + title: cleanedName, + headerRight: () => , + }) + }, [cleanedName, navigation]) + + const JSONView = React.useCallback((): JSX.Element => { + if (data === undefined) { + return <> + } + + const parsed = JSON.parse(data ?? '') + const formatted = JSON.stringify(parsed, null, 2) + const highlighted = syntaxHighlight(formatted) + + const HTML_CONTENT = ` + ${CSS_CODE_STYLES} +
${highlighted}
+ ` + + return ( + + ) + }, [data]) + + let APIResponse = + error !== null ? ( + + ) : !isLoading && !cleanedName ? ( + + ) : isLoading ? ( + + ) : displayMode === 'raw' ? ( + + ) : ( + + ) + + return ( + + setDisplayMode(val ? 'parsed' : 'raw')} + value={displayMode === 'parsed'} + /> + + {APIResponse} + + ) +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: c.systemBackground, + flex: 1, + }, + error: { + padding: 10, + color: c.brickRed, + }, + output: { + marginVertical: 3, + paddingRight: 4, + ...Platform.select({ + ios: iOSUIKit.bodyObject, + android: material.body1Object, + }), + }, +}) + +export const NavigationOptions: NativeStackNavigationOptions = {} diff --git a/source/views/settings/screens/api-test/index.ts b/source/views/settings/screens/api-test/index.ts index 9d8c22dbf3..f93c5389ab 100644 --- a/source/views/settings/screens/api-test/index.ts +++ b/source/views/settings/screens/api-test/index.ts @@ -1,4 +1,8 @@ export { APITestView, NavigationOptions as APITestNavigationOptions, -} from './api-test' +} from './list' +export { + APITestDetailView, + NavigationOptions as APITestDetailNavigationOptions, +} from './detail' diff --git a/source/views/settings/screens/api-test/list.tsx b/source/views/settings/screens/api-test/list.tsx new file mode 100644 index 0000000000..2f66abe81a --- /dev/null +++ b/source/views/settings/screens/api-test/list.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import {View, SectionList, SafeAreaView, StyleSheet} from 'react-native' + +import {ListRow, ListSectionHeader, ListSeparator, Title} from '@frogpond/lists' +import {Column} from '@frogpond/layout' +import {LoadingView, NoticeView} from '@frogpond/notice' +import * as c from '@frogpond/colors' +import {debounceSearch} from '@frogpond/use-debounce' +import {NetworkLoggerButton} from '@frogpond/navigation-buttons' + +import {ServerRoute, useServerRoutes} from './query' +import {ChangeTextEvent} from '../../../../navigation/types' +import {useNavigation} from '@react-navigation/native' +import {NativeStackNavigationOptions} from '@react-navigation/native-stack' + +export const APITestView = (): JSX.Element => { + let navigation = useNavigation() + + let [filterPath, setFilterPath] = React.useState('') + + let { + data: groupedRoutes = [], + error: routesError, + isLoading: isRoutesLoading, + isError: isRoutesError, + refetch: routesRefetch, + } = useServerRoutes() + + React.useLayoutEffect(() => { + navigation.setOptions({ + headerSearchBarOptions: { + autoCapitalize: 'none', + barTintColor: c.systemFill, + // android-only + autoFocus: true, + hideNavigationBar: false, + hideWhenScrolling: false, + onChangeText: (event: ChangeTextEvent) => + setFilterPath(event.nativeEvent.text), + placeholder: '/path/to/uri', + }, + headerRight: () => , + }) + }, [navigation]) + + let showSearchResult = React.useCallback(() => { + navigation.navigate('APITestDetail', { + query: { + displayName: filterPath, + path: filterPath, + params: [], + }, + }) + }, [filterPath, navigation]) + + React.useEffect(() => { + debounceSearch(filterPath, () => showSearchResult()) + }, [filterPath, navigation, showSearchResult]) + + const renderItem = React.useCallback( + (item: ServerRoute) => ( + + navigation.navigate('APITestDetail', {query: item})} + style={styles.serverRouteRow} + > + + {item.displayName} + + + + ), + [navigation], + ) + + return ( + + {isRoutesLoading ? ( + + ) : isRoutesError && routesError instanceof Error ? ( + + ) : !groupedRoutes ? ( + + ) : ( + `${item.path}-${index}`} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="never" + onRefresh={routesRefetch} + refreshing={isRoutesLoading} + renderItem={({item}) => renderItem(item)} + renderSectionHeader={({section: {title}}) => ( + + )} + sections={groupedRoutes} + /> + )} + + ) +} + +const styles = StyleSheet.create({ + serverRouteContainer: { + flex: 1, + backgroundColor: c.systemBackground, + }, + serverRouteRow: { + flexDirection: 'row', + alignItems: 'center', + }, +}) + +export const NavigationOptions: NativeStackNavigationOptions = { + title: 'API Tester', +} diff --git a/source/views/settings/screens/api-test/routes-list.tsx b/source/views/settings/screens/api-test/routes-list.tsx deleted file mode 100644 index e986d21868..0000000000 --- a/source/views/settings/screens/api-test/routes-list.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react' -import {View, SectionList, SafeAreaView, StyleSheet} from 'react-native' - -import {ListRow, ListSectionHeader, ListSeparator, Title} from '@frogpond/lists' -import {Column} from '@frogpond/layout' -import {LoadingView, NoticeView} from '@frogpond/notice' -import * as c from '@frogpond/colors' - -import {useServerRoutes} from './query' - -interface ServerRoutesListParams { - setSearchPath: React.Dispatch> -} - -export const ServerRoutesListView = ( - props: ServerRoutesListParams, -): JSX.Element => { - let { - data: groupedRoutes = [], - error: routesError, - isLoading: isRoutesLoading, - isError: isRoutesError, - refetch: routesRefetch, - } = useServerRoutes() - - return ( - - {isRoutesLoading ? ( - - ) : isRoutesError && routesError instanceof Error ? ( - - ) : !groupedRoutes ? ( - - ) : ( - `${item.path}-${index}`} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="never" - onRefresh={routesRefetch} - refreshing={isRoutesLoading} - renderItem={({item}) => { - return ( - - props.setSearchPath(item.displayName)} - style={styles.serverRouteRow} - > - - {item.displayName} - - - - ) - }} - renderSectionHeader={({section: {title}}) => ( - - )} - sections={groupedRoutes} - /> - )} - - ) -} - -const styles = StyleSheet.create({ - serverRouteContainer: { - flex: 1, - backgroundColor: c.systemBackground, - }, - serverRouteRow: { - flexDirection: 'row', - alignItems: 'center', - }, -})