diff --git a/src/plugins/navigation/__tests__/testURI.node.js b/src/plugins/navigation/__tests__/testURI.node.js new file mode 100644 index 000000000..6c1ba5dd9 --- /dev/null +++ b/src/plugins/navigation/__tests__/testURI.node.js @@ -0,0 +1,54 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + * @flow strict-local + */ + +import { + getRequiredParameters, + parameterIsNumberType, + replaceRequiredParametersWithValues, +} from '../util/uri'; + +test('parse required parameters from uri', () => { + const testURI = + 'fb://test_uri/?parameter1={parameter1}¶meter2={parameter2}'; + const expectedResult = ['{parameter1}', '{parameter2}']; + expect(getRequiredParameters(testURI)).toEqual(expectedResult); +}); + +test('parse required numeric parameters from uri', () => { + const testURI = + 'fb://test_uri/?parameter1={#parameter1}¶meter2={#parameter2}'; + const expectedResult = ['{#parameter1}', '{#parameter2}']; + expect(getRequiredParameters(testURI)).toEqual(expectedResult); +}); + +test('replace required parameters with values', () => { + const testURI = + 'fb://test_uri/?parameter1={parameter1}¶meter2={parameter2}'; + const expectedResult = 'fb://test_uri/?parameter1=okay¶meter2=sure'; + expect( + replaceRequiredParametersWithValues(testURI, ['okay', 'sure']), + ).toEqual(expectedResult); +}); + +test('skip non-required parameters in replacement', () => { + const testURI = + 'fb://test_uri/?parameter1={parameter1}¶meter2={?parameter2}¶meter3={parameter3}'; + const expectedResult = + 'fb://test_uri/?parameter1=okay¶meter2={?parameter2}¶meter3=sure'; + expect( + replaceRequiredParametersWithValues(testURI, ['okay', 'sure']), + ).toEqual(expectedResult); +}); + +test('detect if required parameter is numeric type', () => { + expect(parameterIsNumberType('{#numerictype}')).toBe(true); +}); + +test('detect if required parameter is not numeric type', () => { + expect(parameterIsNumberType('{numerictype}')).toBe(false); +}); diff --git a/src/plugins/navigation/components/RequiredParametersDialog.js b/src/plugins/navigation/components/RequiredParametersDialog.js new file mode 100644 index 000000000..80298f0e7 --- /dev/null +++ b/src/plugins/navigation/components/RequiredParametersDialog.js @@ -0,0 +1,137 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + * @flow strict-local + */ + +import {Button, FlexColumn, Input, Sheet, styled, Glyph, colors} from 'flipper'; +import {replaceRequiredParametersWithValues} from '../util/uri'; +import {useRequiredParameterFormValidator} from '../hooks/requiredParameters'; + +import type {URI} from '../flow-types'; + +type Props = {| + uri: string, + requiredParameters: Array, + shouldShow: boolean, + onHide: ?() => void, + onSubmit: URI => void, +|}; + +const Container = styled(FlexColumn)({ + padding: 10, + width: 600, +}); + +const Title = styled('span')({ + display: 'flex', + marginTop: 8, + marginLeft: 2, + marginBottom: 8, +}); + +const Text = styled('span')({ + lineHeight: 1.3, +}); + +const URIContainer = styled('div')({ + lineHeight: 1.3, + marginLeft: 2, + marginBottom: 8, + overflowWrap: 'break-word', +}); + +const ButtonContainer = styled('div')({ + marginLeft: 'auto', +}); + +const RequiredParameterInput = styled(Input)({ + margin: 0, + marginBottom: 10, + height: 30, +}); + +const WarningIconContainer = styled('span')({ + marginRight: 8, +}); + +export default (props: Props) => { + const {shouldShow, onHide, onSubmit, uri, requiredParameters} = props; + const [isValid, values, setValuesArray] = useRequiredParameterFormValidator( + requiredParameters, + ); + if (uri == null || !shouldShow) { + return null; + } else { + return ( + + {hide => { + return ( + + + <WarningIconContainer> + <Glyph + name="caution-triangle" + size={16} + variant="filled" + color={colors.yellow} + /> + </WarningIconContainer> + <Text> + This uri has required parameters denoted by {'{parameter}'}. + Numeric fields are spcified with a '#' symbol. Please fix the + errors to navigate to the specified uri. + </Text> + + {requiredParameters.map((paramater, idx) => ( + + setValuesArray([ + ...values.slice(0, idx), + event.target.value, + ...values.slice(idx + 1), + ]) + } + placeholder={paramater} + /> + ))} + {uri} + + + + + + ); + }} + + ); + } +}; diff --git a/src/plugins/navigation/components/SearchBar.js b/src/plugins/navigation/components/SearchBar.js index 9f3c89301..df91dcdf7 100644 --- a/src/plugins/navigation/components/SearchBar.js +++ b/src/plugins/navigation/components/SearchBar.js @@ -6,14 +6,7 @@ * @flow strict-local */ -import { - Component, - styled, - SearchBox, - SearchInput, - Toolbar, - Glyph, -} from 'flipper'; +import {Component, styled, SearchBox, SearchInput, Toolbar} from 'flipper'; import {AutoCompleteSheet, IconButton, FavoriteButton} from './'; import type {AutoCompleteProvider, Bookmark} from '../flow-types'; @@ -23,6 +16,7 @@ type Props = {| onNavigate: (query: string) => void, bookmarks: Map, providers: Array, + uriFromAbove: string, |}; type State = {| @@ -30,6 +24,7 @@ type State = {| inputFocused: boolean, autoCompleteSheetOpen: boolean, searchInputValue: string, + prevURIFromAbove: string, |}; const IconContainer = styled('div')({ @@ -68,6 +63,7 @@ class SearchBar extends Component { autoCompleteSheetOpen: false, query: '', searchInputValue: '', + prevURIFromAbove: '', }; favorite = (searchInputValue: string) => { @@ -84,6 +80,19 @@ class SearchBar extends Component { this.setState({query: value, searchInputValue: value}); }; + static getDerivedStateFromProps = (newProps: Props, state: State) => { + const {uriFromAbove: newURIFromAbove} = newProps; + const {prevURIFromAbove} = state; + if (newURIFromAbove !== prevURIFromAbove) { + return { + searchInputValue: newURIFromAbove, + query: newURIFromAbove, + prevURIFromAbove: newURIFromAbove, + }; + } + return null; + }; + render = () => { const {bookmarks, providers} = this.props; const { diff --git a/src/plugins/navigation/components/index.js b/src/plugins/navigation/components/index.js index f5d977f58..265d926d5 100644 --- a/src/plugins/navigation/components/index.js +++ b/src/plugins/navigation/components/index.js @@ -11,6 +11,7 @@ export {default as BookmarksSidebar} from './BookmarksSidebar'; export {default as FavoriteButton} from './FavoriteButton'; export {default as IconButton} from './IconButton'; export {default as NavigationInfoBox} from './NavigationInfoBox'; +export {default as RequiredParametersDialog} from './RequiredParametersDialog'; export {default as SaveBookmarkDialog} from './SaveBookmarkDialog'; export {default as ScrollableFlexColumn} from './ScrollableFlexColumn'; export {default as SearchBar} from './SearchBar'; diff --git a/src/plugins/navigation/flow-types.js b/src/plugins/navigation/flow-types.js index fed601373..37b07ebde 100644 --- a/src/plugins/navigation/flow-types.js +++ b/src/plugins/navigation/flow-types.js @@ -9,8 +9,11 @@ export type URI = string; export type State = {| + currentURI: string, shouldShowSaveBookmarkDialog: boolean, + shouldShowURIErrorDialog: boolean, saveBookmarkURI: ?URI, + requiredParameters: Array, |}; export type PersistedState = {| diff --git a/src/plugins/navigation/hooks/requiredParameters.js b/src/plugins/navigation/hooks/requiredParameters.js new file mode 100644 index 000000000..90031c56c --- /dev/null +++ b/src/plugins/navigation/hooks/requiredParameters.js @@ -0,0 +1,41 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + * @flow strict-local + */ + +import {useEffect, useState} from 'react'; +import {parameterIsNumberType} from '../util/uri'; + +const validateParameter = (value: string, parameter: string) => { + return ( + value.length > 0 && + (parameterIsNumberType(parameter) ? !isNaN(parseInt(value, 10)) : true) + ); +}; + +export const useRequiredParameterFormValidator = ( + requiredParameters: Array, +) => { + const [values, setValuesArray] = useState>( + requiredParameters.map(() => ''), + ); + const [isValid, setIsValid] = useState(false); + useEffect(() => { + if (requiredParameters.length != values.length) { + setValuesArray(requiredParameters.map(() => '')); + } + if ( + values.every((value, idx) => + validateParameter(value, requiredParameters[idx]), + ) + ) { + setIsValid(true); + } else { + setIsValid(false); + } + }); + return [isValid, values, setValuesArray]; +}; diff --git a/src/plugins/navigation/index.js b/src/plugins/navigation/index.js index 4d821b6f5..96d3cad0a 100644 --- a/src/plugins/navigation/index.js +++ b/src/plugins/navigation/index.js @@ -13,6 +13,7 @@ import { SearchBar, Timeline, ScrollableFlexColumn, + RequiredParametersDialog, } from './components'; import { removeBookmark, @@ -25,6 +26,7 @@ import { DefaultProvider, } from './util/autoCompleteProvider'; import {getAppMatchPatterns} from './util/appMatchPatterns'; +import {getRequiredParameters} from './util/uri'; import type { State, @@ -50,6 +52,9 @@ export default class extends FlipperPlugin { state = { shouldShowSaveBookmarkDialog: false, saveBookmarkURI: null, + shouldShowURIErrorDialog: false, + currentURI: '', + requiredParameters: [], }; static persistedStateReducer = ( @@ -105,9 +110,18 @@ export default class extends FlipperPlugin { }; navigateTo = (query: string) => { - this.getDevice().then(device => { - device.navigateToLocation(query); - }); + this.setState({currentURI: query}); + const requiredParameters = getRequiredParameters(query); + if (requiredParameters.length === 0) { + this.getDevice().then(device => { + device.navigateToLocation(query); + }); + } else { + this.setState({ + requiredParameters, + shouldShowURIErrorDialog: true, + }); + } }; onFavorite = (uri: string) => { @@ -141,7 +155,13 @@ export default class extends FlipperPlugin { }; render() { - const {saveBookmarkURI, shouldShowSaveBookmarkDialog} = this.state; + const { + currentURI, + saveBookmarkURI, + shouldShowSaveBookmarkDialog, + shouldShowURIErrorDialog, + requiredParameters, + } = this.state; const { bookmarks, bookmarksProvider, @@ -156,6 +176,7 @@ export default class extends FlipperPlugin { bookmarks={bookmarks} onNavigate={this.navigateTo} onFavorite={this.onFavorite} + uriFromAbove={currentURI} /> { onSubmit={this.addBookmark} onRemove={this.removeBookmark} /> + this.setState({shouldShowURIErrorDialog: false})} + uri={currentURI} + requiredParameters={requiredParameters} + onSubmit={this.navigateTo} + /> ); } diff --git a/src/plugins/navigation/util/uri.js b/src/plugins/navigation/util/uri.js index bd4262489..f3559bbb4 100644 --- a/src/plugins/navigation/util/uri.js +++ b/src/plugins/navigation/util/uri.js @@ -23,3 +23,38 @@ export const parseURIParameters: string => Map = ( } return parametersMap; }; + +export const parameterIsNumberType = (parameter: string) => { + const regExp = /^{(#|\?#)/g; + return regExp.test(parameter); +}; + +export const replaceRequiredParametersWithValues = ( + uri: string, + values: Array, +) => { + const parameterRegExp = /{[^?]*?}/g; + const replaceRegExp = /{[^?]*?}/; + let newURI = uri; + let index = 0; + let match = parameterRegExp.exec(uri); + while (match != null) { + newURI = newURI.replace(replaceRegExp, values[index]); + match = parameterRegExp.exec(uri); + index++; + } + return newURI; +}; + +export const getRequiredParameters = (uri: string) => { + const parameterRegExp = /{[^?]*?}/g; + const matches: Array = []; + let match = parameterRegExp.exec(uri); + while (match != null) { + if (match[0]) { + matches.push(match[0]); + } + match = parameterRegExp.exec(uri); + } + return matches; +};