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:
Benjamin Elo
2019-08-12 03:12:53 -07:00
committed by Facebook Github Bot
parent ce34c20506
commit 0d5850d723
8 changed files with 320 additions and 12 deletions

View 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}&parameter2={parameter2}';
const expectedResult = ['{parameter1}', '{parameter2}'];
expect(getRequiredParameters(testURI)).toEqual(expectedResult);
});
test('parse required numeric parameters from uri', () => {
const testURI =
'fb://test_uri/?parameter1={#parameter1}&parameter2={#parameter2}';
const expectedResult = ['{#parameter1}', '{#parameter2}'];
expect(getRequiredParameters(testURI)).toEqual(expectedResult);
});
test('replace required parameters with values', () => {
const testURI =
'fb://test_uri/?parameter1={parameter1}&parameter2={parameter2}';
const expectedResult = 'fb://test_uri/?parameter1=okay&parameter2=sure';
expect(
replaceRequiredParametersWithValues(testURI, ['okay', 'sure']),
).toEqual(expectedResult);
});
test('skip non-required parameters in replacement', () => {
const testURI =
'fb://test_uri/?parameter1={parameter1}&parameter2={?parameter2}&parameter3={parameter3}';
const expectedResult =
'fb://test_uri/?parameter1=okay&parameter2={?parameter2}&parameter3=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);
});

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

View File

@@ -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<string, Bookmark>,
providers: Array<AutoCompleteProvider>,
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<Props, State> {
autoCompleteSheetOpen: false,
query: '',
searchInputValue: '',
prevURIFromAbove: '',
};
favorite = (searchInputValue: string) => {
@@ -84,6 +80,19 @@ class SearchBar extends Component<Props, State> {
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 {

View File

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

View File

@@ -9,8 +9,11 @@
export type URI = string;
export type State = {|
currentURI: string,
shouldShowSaveBookmarkDialog: boolean,
shouldShowURIErrorDialog: boolean,
saveBookmarkURI: ?URI,
requiredParameters: Array<string>,
|};
export type PersistedState = {|

View 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];
};

View File

@@ -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, {}, PersistedState> {
state = {
shouldShowSaveBookmarkDialog: false,
saveBookmarkURI: null,
shouldShowURIErrorDialog: false,
currentURI: '',
requiredParameters: [],
};
static persistedStateReducer = (
@@ -105,9 +110,18 @@ export default class extends FlipperPlugin<State, {}, PersistedState> {
};
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<State, {}, PersistedState> {
};
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<State, {}, PersistedState> {
bookmarks={bookmarks}
onNavigate={this.navigateTo}
onFavorite={this.onFavorite}
uriFromAbove={currentURI}
/>
<Timeline
bookmarks={bookmarks}
@@ -174,6 +195,13 @@ export default class extends FlipperPlugin<State, {}, PersistedState> {
onSubmit={this.addBookmark}
onRemove={this.removeBookmark}
/>
<RequiredParametersDialog
shouldShow={shouldShowURIErrorDialog}
onHide={() => this.setState({shouldShowURIErrorDialog: false})}
uri={currentURI}
requiredParameters={requiredParameters}
onSubmit={this.navigateTo}
/>
</ScrollableFlexColumn>
);
}

View File

@@ -23,3 +23,38 @@ export const parseURIParameters: string => Map<string, string> = (
}
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;
};