Added URI validation functions and UI for correcting errors
Summary: Some uris parsed from the device contain required parameters. Here we parse the uri and check if there is a required parameter on navigation. If there is we alert the user to correct the error. In the next diff, I will strip away non-required parameters if they are present but not filled in. Reviewed By: danielbuechele Differential Revision: D16710944 fbshipit-source-id: ea32cfe60e2bb5e4f395caebf585ba1b220dcefe
This commit is contained in:
committed by
Facebook Github Bot
parent
ce34c20506
commit
0d5850d723
54
src/plugins/navigation/__tests__/testURI.node.js
Normal file
54
src/plugins/navigation/__tests__/testURI.node.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
137
src/plugins/navigation/components/RequiredParametersDialog.js
Normal file
137
src/plugins/navigation/components/RequiredParametersDialog.js
Normal file
@@ -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<string>,
|
||||||
|
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 (
|
||||||
|
<Sheet onHideSheet={onHide}>
|
||||||
|
{hide => {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Title>
|
||||||
|
<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>
|
||||||
|
</Title>
|
||||||
|
{requiredParameters.map((paramater, idx) => (
|
||||||
|
<RequiredParameterInput
|
||||||
|
key={idx}
|
||||||
|
onChange={event =>
|
||||||
|
setValuesArray([
|
||||||
|
...values.slice(0, idx),
|
||||||
|
event.target.value,
|
||||||
|
...values.slice(idx + 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
placeholder={paramater}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<URIContainer>{uri}</URIContainer>
|
||||||
|
<ButtonContainer>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (onHide != null) {
|
||||||
|
onHide();
|
||||||
|
}
|
||||||
|
setValuesArray([]);
|
||||||
|
hide();
|
||||||
|
}}
|
||||||
|
compact
|
||||||
|
padded>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={isValid ? 'primary' : null}
|
||||||
|
onClick={() => {
|
||||||
|
onSubmit(replaceRequiredParametersWithValues(uri, values));
|
||||||
|
if (onHide != null) {
|
||||||
|
onHide();
|
||||||
|
}
|
||||||
|
setValuesArray([]);
|
||||||
|
hide();
|
||||||
|
}}
|
||||||
|
disabled={!isValid}
|
||||||
|
compact
|
||||||
|
padded>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</ButtonContainer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,14 +6,7 @@
|
|||||||
* @flow strict-local
|
* @flow strict-local
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {Component, styled, SearchBox, SearchInput, Toolbar} from 'flipper';
|
||||||
Component,
|
|
||||||
styled,
|
|
||||||
SearchBox,
|
|
||||||
SearchInput,
|
|
||||||
Toolbar,
|
|
||||||
Glyph,
|
|
||||||
} from 'flipper';
|
|
||||||
import {AutoCompleteSheet, IconButton, FavoriteButton} from './';
|
import {AutoCompleteSheet, IconButton, FavoriteButton} from './';
|
||||||
|
|
||||||
import type {AutoCompleteProvider, Bookmark} from '../flow-types';
|
import type {AutoCompleteProvider, Bookmark} from '../flow-types';
|
||||||
@@ -23,6 +16,7 @@ type Props = {|
|
|||||||
onNavigate: (query: string) => void,
|
onNavigate: (query: string) => void,
|
||||||
bookmarks: Map<string, Bookmark>,
|
bookmarks: Map<string, Bookmark>,
|
||||||
providers: Array<AutoCompleteProvider>,
|
providers: Array<AutoCompleteProvider>,
|
||||||
|
uriFromAbove: string,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
type State = {|
|
type State = {|
|
||||||
@@ -30,6 +24,7 @@ type State = {|
|
|||||||
inputFocused: boolean,
|
inputFocused: boolean,
|
||||||
autoCompleteSheetOpen: boolean,
|
autoCompleteSheetOpen: boolean,
|
||||||
searchInputValue: string,
|
searchInputValue: string,
|
||||||
|
prevURIFromAbove: string,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
const IconContainer = styled('div')({
|
const IconContainer = styled('div')({
|
||||||
@@ -68,6 +63,7 @@ class SearchBar extends Component<Props, State> {
|
|||||||
autoCompleteSheetOpen: false,
|
autoCompleteSheetOpen: false,
|
||||||
query: '',
|
query: '',
|
||||||
searchInputValue: '',
|
searchInputValue: '',
|
||||||
|
prevURIFromAbove: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
favorite = (searchInputValue: string) => {
|
favorite = (searchInputValue: string) => {
|
||||||
@@ -84,6 +80,19 @@ class SearchBar extends Component<Props, State> {
|
|||||||
this.setState({query: value, searchInputValue: value});
|
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 = () => {
|
render = () => {
|
||||||
const {bookmarks, providers} = this.props;
|
const {bookmarks, providers} = this.props;
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {default as BookmarksSidebar} from './BookmarksSidebar';
|
|||||||
export {default as FavoriteButton} from './FavoriteButton';
|
export {default as FavoriteButton} from './FavoriteButton';
|
||||||
export {default as IconButton} from './IconButton';
|
export {default as IconButton} from './IconButton';
|
||||||
export {default as NavigationInfoBox} from './NavigationInfoBox';
|
export {default as NavigationInfoBox} from './NavigationInfoBox';
|
||||||
|
export {default as RequiredParametersDialog} from './RequiredParametersDialog';
|
||||||
export {default as SaveBookmarkDialog} from './SaveBookmarkDialog';
|
export {default as SaveBookmarkDialog} from './SaveBookmarkDialog';
|
||||||
export {default as ScrollableFlexColumn} from './ScrollableFlexColumn';
|
export {default as ScrollableFlexColumn} from './ScrollableFlexColumn';
|
||||||
export {default as SearchBar} from './SearchBar';
|
export {default as SearchBar} from './SearchBar';
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
export type URI = string;
|
export type URI = string;
|
||||||
|
|
||||||
export type State = {|
|
export type State = {|
|
||||||
|
currentURI: string,
|
||||||
shouldShowSaveBookmarkDialog: boolean,
|
shouldShowSaveBookmarkDialog: boolean,
|
||||||
|
shouldShowURIErrorDialog: boolean,
|
||||||
saveBookmarkURI: ?URI,
|
saveBookmarkURI: ?URI,
|
||||||
|
requiredParameters: Array<string>,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
export type PersistedState = {|
|
export type PersistedState = {|
|
||||||
|
|||||||
41
src/plugins/navigation/hooks/requiredParameters.js
Normal file
41
src/plugins/navigation/hooks/requiredParameters.js
Normal file
@@ -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<string>,
|
||||||
|
) => {
|
||||||
|
const [values, setValuesArray] = useState<Array<string>>(
|
||||||
|
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];
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SearchBar,
|
SearchBar,
|
||||||
Timeline,
|
Timeline,
|
||||||
ScrollableFlexColumn,
|
ScrollableFlexColumn,
|
||||||
|
RequiredParametersDialog,
|
||||||
} from './components';
|
} from './components';
|
||||||
import {
|
import {
|
||||||
removeBookmark,
|
removeBookmark,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
DefaultProvider,
|
DefaultProvider,
|
||||||
} from './util/autoCompleteProvider';
|
} from './util/autoCompleteProvider';
|
||||||
import {getAppMatchPatterns} from './util/appMatchPatterns';
|
import {getAppMatchPatterns} from './util/appMatchPatterns';
|
||||||
|
import {getRequiredParameters} from './util/uri';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
State,
|
State,
|
||||||
@@ -50,6 +52,9 @@ export default class extends FlipperPlugin<State, {}, PersistedState> {
|
|||||||
state = {
|
state = {
|
||||||
shouldShowSaveBookmarkDialog: false,
|
shouldShowSaveBookmarkDialog: false,
|
||||||
saveBookmarkURI: null,
|
saveBookmarkURI: null,
|
||||||
|
shouldShowURIErrorDialog: false,
|
||||||
|
currentURI: '',
|
||||||
|
requiredParameters: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
static persistedStateReducer = (
|
static persistedStateReducer = (
|
||||||
@@ -105,9 +110,18 @@ export default class extends FlipperPlugin<State, {}, PersistedState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
navigateTo = (query: string) => {
|
navigateTo = (query: string) => {
|
||||||
this.getDevice().then(device => {
|
this.setState({currentURI: query});
|
||||||
device.navigateToLocation(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) => {
|
onFavorite = (uri: string) => {
|
||||||
@@ -141,7 +155,13 @@ export default class extends FlipperPlugin<State, {}, PersistedState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {saveBookmarkURI, shouldShowSaveBookmarkDialog} = this.state;
|
const {
|
||||||
|
currentURI,
|
||||||
|
saveBookmarkURI,
|
||||||
|
shouldShowSaveBookmarkDialog,
|
||||||
|
shouldShowURIErrorDialog,
|
||||||
|
requiredParameters,
|
||||||
|
} = this.state;
|
||||||
const {
|
const {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
bookmarksProvider,
|
bookmarksProvider,
|
||||||
@@ -156,6 +176,7 @@ export default class extends FlipperPlugin<State, {}, PersistedState> {
|
|||||||
bookmarks={bookmarks}
|
bookmarks={bookmarks}
|
||||||
onNavigate={this.navigateTo}
|
onNavigate={this.navigateTo}
|
||||||
onFavorite={this.onFavorite}
|
onFavorite={this.onFavorite}
|
||||||
|
uriFromAbove={currentURI}
|
||||||
/>
|
/>
|
||||||
<Timeline
|
<Timeline
|
||||||
bookmarks={bookmarks}
|
bookmarks={bookmarks}
|
||||||
@@ -174,6 +195,13 @@ export default class extends FlipperPlugin<State, {}, PersistedState> {
|
|||||||
onSubmit={this.addBookmark}
|
onSubmit={this.addBookmark}
|
||||||
onRemove={this.removeBookmark}
|
onRemove={this.removeBookmark}
|
||||||
/>
|
/>
|
||||||
|
<RequiredParametersDialog
|
||||||
|
shouldShow={shouldShowURIErrorDialog}
|
||||||
|
onHide={() => this.setState({shouldShowURIErrorDialog: false})}
|
||||||
|
uri={currentURI}
|
||||||
|
requiredParameters={requiredParameters}
|
||||||
|
onSubmit={this.navigateTo}
|
||||||
|
/>
|
||||||
</ScrollableFlexColumn>
|
</ScrollableFlexColumn>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,38 @@ export const parseURIParameters: string => Map<string, string> = (
|
|||||||
}
|
}
|
||||||
return parametersMap;
|
return parametersMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parameterIsNumberType = (parameter: string) => {
|
||||||
|
const regExp = /^{(#|\?#)/g;
|
||||||
|
return regExp.test(parameter);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceRequiredParametersWithValues = (
|
||||||
|
uri: string,
|
||||||
|
values: Array<string>,
|
||||||
|
) => {
|
||||||
|
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<string> = [];
|
||||||
|
let match = parameterRegExp.exec(uri);
|
||||||
|
while (match != null) {
|
||||||
|
if (match[0]) {
|
||||||
|
matches.push(match[0]);
|
||||||
|
}
|
||||||
|
match = parameterRegExp.exec(uri);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user