Support auto completion on discovered bookmarks and filling out params

Summary:
This diff adds support for finding appPatterns (not sure how the feature is called) in the device, and auto completing on it.

Also improved the styling of bookmark sections.

This diff also adds support of showing a dialog in which params an be filled out if needed.

The behavior around optional arguments seems buggy, as in, no dialog will show up, but since I didn't want to change the logic around this unilaterally, left it as-is for now.

Updated the dialog to Ant so that the renderReactRoot utility could be used safely

Reviewed By: cekkaewnumchai

Differential Revision: D24889855

fbshipit-source-id: 6af264abec2e9e5b921ef7da6deb1d0021615e9e
This commit is contained in:
Michel Weststrate
2020-11-12 04:13:16 -08:00
committed by Facebook GitHub Bot
parent 5118727cb7
commit 273b895e30
8 changed files with 171 additions and 185 deletions

View File

@@ -8,14 +8,18 @@
*/ */
import React, {useCallback, useMemo} from 'react'; import React, {useCallback, useMemo} from 'react';
import {AutoComplete, Input} from 'antd'; import {AutoComplete, Input, Typography} from 'antd';
import {StarFilled, StarOutlined} from '@ant-design/icons'; import {StarFilled, StarOutlined} from '@ant-design/icons';
import {useStore} from '../../utils/useStore'; import {useStore} from '../../utils/useStore';
import {NUX, useValue} from 'flipper-plugin'; import {Layout, NUX, useValue} from 'flipper-plugin';
import {navPluginStateSelector} from '../../chrome/LocationsButton'; import {navPluginStateSelector} from '../../chrome/LocationsButton';
// eslint-disable-next-line flipper/no-relative-imports-across-packages // eslint-disable-next-line flipper/no-relative-imports-across-packages
import type {NavigationPlugin} from '../../../../plugins/navigation/index'; import type {NavigationPlugin} from '../../../../plugins/navigation/index';
import {useMemoize} from '../../utils/useMemoize';
import styled from '@emotion/styled';
const {Text} = Typography;
export function BookmarkSection() { export function BookmarkSection() {
const navPlugin = useStore(navPluginStateSelector); const navPlugin = useStore(navPluginStateSelector);
@@ -32,15 +36,22 @@ export function BookmarkSection() {
function BookmarkSectionInput({navPlugin}: {navPlugin: NavigationPlugin}) { function BookmarkSectionInput({navPlugin}: {navPlugin: NavigationPlugin}) {
const currentURI = useValue(navPlugin.currentURI); const currentURI = useValue(navPlugin.currentURI);
const bookmarks = useValue(navPlugin.bookmarks); const bookmarks = useValue(navPlugin.bookmarks);
const patterns = useValue(navPlugin.appMatchPatterns);
const isBookmarked = useMemo(() => bookmarks.has(currentURI), [ const isBookmarked = useMemo(() => bookmarks.has(currentURI), [
bookmarks, bookmarks,
currentURI, currentURI,
]); ]);
const autoCompleteItems = useMemoize(
navPlugin.getAutoCompleteAppMatchPatterns,
[currentURI, bookmarks, patterns, 20],
);
const handleBookmarkClick = useCallback(() => { const handleBookmarkClick = useCallback(() => {
if (isBookmarked) { if (isBookmarked) {
navPlugin.removeBookmark(currentURI); navPlugin.removeBookmark(currentURI);
} else { } else if (currentURI) {
navPlugin.addBookmark({ navPlugin.addBookmark({
uri: currentURI, uri: currentURI,
commonName: null, commonName: null,
@@ -55,15 +66,31 @@ function BookmarkSectionInput({navPlugin}: {navPlugin: NavigationPlugin}) {
); );
return ( return (
<AutoComplete <StyledAutoComplete
dropdownMatchSelectWidth={500}
value={currentURI} value={currentURI}
onSelect={navPlugin.navigateTo} onSelect={navPlugin.navigateTo}
options={Array.from(bookmarks.values()).map((bookmark) => ({ style={{flex: 1}}
options={[
{
label: <Text strong>Bookmarks</Text>,
options: Array.from(bookmarks.values()).map((bookmark) => ({
value: bookmark.uri, value: bookmark.uri,
label: bookmark.commonName label: (
? `${bookmark.commonName} - ${bookmark.uri}` <NavigationEntry label={bookmark.commonName} uri={bookmark.uri} />
: bookmark.uri, ),
}))}> })),
},
{
label: <Text strong>Entry points</Text>,
options: autoCompleteItems.map((value) => ({
value: value.pattern,
label: (
<NavigationEntry label={value.className} uri={value.pattern} />
),
})),
},
]}>
<Input <Input
addonAfter={bookmarkButton} addonAfter={bookmarkButton}
defaultValue="<select a bookmark>" defaultValue="<select a bookmark>"
@@ -71,10 +98,27 @@ function BookmarkSectionInput({navPlugin}: {navPlugin: NavigationPlugin}) {
onChange={(e) => { onChange={(e) => {
navPlugin.currentURI.set(e.target.value); navPlugin.currentURI.set(e.target.value);
}} }}
onPressEnter={(e) => { onPressEnter={() => {
navPlugin.navigateTo(currentURI); navPlugin.navigateTo(currentURI);
}} }}
/> />
</AutoComplete> </StyledAutoComplete>
); );
} }
function NavigationEntry({label, uri}: {label: string | null; uri: string}) {
return (
<Layout.Container>
<Text>{label ?? uri}</Text>
<Text type="secondary">{uri}</Text>
</Layout.Container>
);
}
const StyledAutoComplete = styled(AutoComplete)({
display: 'flex',
flex: 1,
'.ant-select-selector': {
flex: 1,
},
});

View File

@@ -10,11 +10,11 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {Modal, Button, message, Alert} from 'antd'; import {Modal, Button, message, Alert} from 'antd';
import {AndroidOutlined, AppleOutlined} from '@ant-design/icons'; import {AndroidOutlined, AppleOutlined} from '@ant-design/icons';
import {renderReactRoot} from '../../utils/renderReactRoot';
import {Store} from '../../reducers'; import {Store} from '../../reducers';
import {useStore} from '../../utils/useStore'; import {useStore} from '../../utils/useStore';
import {launchEmulator} from '../../devices/AndroidDevice'; import {launchEmulator} from '../../devices/AndroidDevice';
import {Layout} from 'flipper-plugin'; import {Layout, renderReactRoot} from 'flipper-plugin';
import {Provider} from 'react-redux';
import { import {
launchSimulator, launchSimulator,
getSimulators, getSimulators,
@@ -22,12 +22,11 @@ import {
} from '../../dispatcher/iOSDevice'; } from '../../dispatcher/iOSDevice';
export function showEmulatorLauncher(store: Store) { export function showEmulatorLauncher(store: Store) {
renderReactRoot( renderReactRoot((unmount) => (
(unmount) => ( <Provider store={store}>
<LaunchEmulatorDialog onClose={unmount} getSimulators={getSimulators} /> <LaunchEmulatorDialog onClose={unmount} getSimulators={getSimulators} />
), </Provider>
store, ));
);
} }
type GetSimulators = typeof getSimulators; type GetSimulators = typeof getSimulators;

View File

@@ -36,6 +36,8 @@ export {theme} from './ui/theme';
export {Layout} from './ui/Layout'; export {Layout} from './ui/Layout';
export {NUX, NuxManagerContext, createNuxManager} from './ui/NUX'; export {NUX, NuxManagerContext, createNuxManager} from './ui/NUX';
export {renderReactRoot} from './utils/renderReactRoot';
// It's not ideal that this exists in flipper-plugin sources directly, // It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors. // but is the least pain for plugin authors.
// Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin) // Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)

View File

@@ -9,8 +9,6 @@
import React from 'react'; import React from 'react';
import {render, unmountComponentAtNode} from 'react-dom'; import {render, unmountComponentAtNode} from 'react-dom';
import {Provider} from 'react-redux';
import {Store} from '../reducers/';
/** /**
* This utility creates a fresh react render hook, which is great to render elements imperatively, like opening dialogs. * This utility creates a fresh react render hook, which is great to render elements imperatively, like opening dialogs.
@@ -18,16 +16,13 @@ import {Store} from '../reducers/';
*/ */
export function renderReactRoot( export function renderReactRoot(
handler: (unmount: () => void) => React.ReactElement, handler: (unmount: () => void) => React.ReactElement,
store: Store,
): void { ): void {
const div = document.body.appendChild(document.createElement('div')); const div = document.body.appendChild(document.createElement('div'));
render( render(
<Provider store={store}> handler(() => {
{handler(() => {
unmountComponentAtNode(div); unmountComponentAtNode(div);
div.remove(); div.remove();
})} }),
</Provider>,
div, div,
); );
} }

View File

@@ -7,7 +7,8 @@
* @format * @format
*/ */
import {Button, FlexColumn, Input, Sheet, styled, Glyph, colors} from 'flipper'; import {Modal, Button, Alert, Input, Typography} from 'antd';
import {Layout} from 'flipper-plugin';
import { import {
replaceRequiredParametersWithValues, replaceRequiredParametersWithValues,
parameterIsNumberType, parameterIsNumberType,
@@ -23,84 +24,49 @@ import {URI} from '../types';
type Props = { type Props = {
uri: string; uri: string;
requiredParameters: Array<string>; requiredParameters: Array<string>;
shouldShow: boolean; onHide: () => void;
onHide?: () => void;
onSubmit: (uri: URI) => void; onSubmit: (uri: 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 ErrorLabel = styled.span({
color: colors.yellow,
lineHeight: 1.4,
});
const URIContainer = styled.div({
lineHeight: 1.3,
marginLeft: 2,
marginBottom: 8,
marginTop: 10,
overflowWrap: 'break-word',
});
const ButtonContainer = styled.div({
marginLeft: 'auto',
});
const RequiredParameterInput = styled(Input)({
margin: 0,
marginTop: 8,
height: 30,
width: '100%',
});
const WarningIconContainer = styled.span({
marginRight: 8,
});
export default (props: Props) => { export default (props: Props) => {
const {shouldShow, onHide, onSubmit, uri, requiredParameters} = props; const {onHide, onSubmit, uri, requiredParameters} = props;
const {isValid, values, setValuesArray} = useRequiredParameterFormValidator( const {isValid, values, setValuesArray} = useRequiredParameterFormValidator(
requiredParameters, requiredParameters,
); );
if (uri == null || !shouldShow) {
return null;
} else {
return ( return (
<Sheet onHideSheet={onHide}> <Modal
{(hide: () => void) => { visible
return ( onCancel={onHide}
<Container> title="Provide bookmark details"
<Title> footer={
<WarningIconContainer> <>
<Glyph <Button
name="caution-triangle" onClick={() => {
size={16} onHide();
variant="filled" setValuesArray([]);
color={colors.yellow} }}>
Cancel
</Button>
<Button
type={'primary'}
onClick={() => {
onSubmit(replaceRequiredParametersWithValues(uri, values));
onHide();
}}
disabled={!isValid}>
Submit
</Button>
</>
}>
<Layout.Container gap>
<Alert
type="info"
message="This uri has required parameters denoted by '{parameter}'}."
/> />
</WarningIconContainer>
<Text>
This uri has required parameters denoted by {'{parameter}'}.
</Text>
</Title>
{requiredParameters.map((paramater, idx) => ( {requiredParameters.map((paramater, idx) => (
<div key={idx}> <div key={idx}>
<RequiredParameterInput <Input
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setValuesArray([ setValuesArray([
...values.slice(0, idx), ...values.slice(0, idx),
@@ -114,51 +80,20 @@ export default (props: Props) => {
{values[idx] && {values[idx] &&
parameterIsNumberType(paramater) && parameterIsNumberType(paramater) &&
!validateParameter(values[idx], paramater) ? ( !validateParameter(values[idx], paramater) ? (
<ErrorLabel>Parameter must be a number</ErrorLabel> <Alert type="error" message="Parameter must be a number" />
) : null} ) : null}
{values[idx] && {values[idx] &&
parameterIsBooleanType(paramater) && parameterIsBooleanType(paramater) &&
!validateParameter(values[idx], paramater) ? ( !validateParameter(values[idx], paramater) ? (
<ErrorLabel> <Alert
Parameter must be either 'true' or 'false' type="error"
</ErrorLabel> message="Parameter must be either 'true' or 'false'"
/>
) : null} ) : null}
</div> </div>
))} ))}
<URIContainer>{liveEdit(uri, values)}</URIContainer> <Typography.Text code>{liveEdit(uri, values)}</Typography.Text>
<ButtonContainer> </Layout.Container>
<Button </Modal>
onClick={() => {
if (onHide != null) {
onHide();
}
setValuesArray([]);
hide();
}}
compact
padded>
Cancel
</Button>
<Button
type={isValid ? 'primary' : undefined}
onClick={() => {
onSubmit(replaceRequiredParametersWithValues(uri, values));
if (onHide != null) {
onHide();
}
setValuesArray([]);
hide();
}}
disabled={!isValid}
compact
padded>
Submit
</Button>
</ButtonContainer>
</Container>
); );
}}
</Sheet>
);
}
}; };

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
import {useEffect, useState} from 'react'; import {useMemo, useState} from 'react';
import {validateParameter} from '../util/uri'; import {validateParameter} from '../util/uri';
export const useRequiredParameterFormValidator = ( export const useRequiredParameterFormValidator = (
@@ -16,8 +16,7 @@ export const useRequiredParameterFormValidator = (
const [values, setValuesArray] = useState<Array<string>>( const [values, setValuesArray] = useState<Array<string>>(
requiredParameters.map(() => ''), requiredParameters.map(() => ''),
); );
const [isValid, setIsValid] = useState(false); const isValid = useMemo(() => {
useEffect(() => {
if (requiredParameters.length != values.length) { if (requiredParameters.length != values.length) {
setValuesArray(requiredParameters.map(() => '')); setValuesArray(requiredParameters.map(() => ''));
} }
@@ -26,10 +25,10 @@ export const useRequiredParameterFormValidator = (
validateParameter(value, requiredParameters[idx]), validateParameter(value, requiredParameters[idx]),
) )
) { ) {
setIsValid(true); return true;
} else { } else {
setIsValid(false); return false;
} }
}); }, [requiredParameters, values]);
return {isValid, values, setValuesArray}; return {isValid, values, setValuesArray};
}; };

View File

@@ -41,6 +41,7 @@ import {
useValue, useValue,
usePlugin, usePlugin,
Layout, Layout,
renderReactRoot,
} from 'flipper-plugin'; } from 'flipper-plugin';
export type State = { export type State = {
@@ -71,8 +72,6 @@ export function plugin(client: PluginClient<Events, Methods>) {
persist: 'appMatchPatterns', persist: 'appMatchPatterns',
}); });
const currentURI = createState(''); const currentURI = createState('');
const shouldShowURIErrorDialog = createState(false);
const requiredParameters = createState<string[]>([]);
const shouldShowSaveBookmarkDialog = createState(false); const shouldShowSaveBookmarkDialog = createState(false);
const saveBookmarkURI = createState<null | string>(null); const saveBookmarkURI = createState<null | string>(null);
@@ -131,13 +130,18 @@ export function plugin(client: PluginClient<Events, Methods>) {
); );
} }
} else { } else {
requiredParameters.set(params); renderReactRoot((unmount) => (
shouldShowURIErrorDialog.set(true); <RequiredParametersDialog
onHide={unmount}
uri={filteredQuery}
requiredParameters={params}
onSubmit={navigateTo}
/>
));
} }
} }
function onFavorite(uri: string) { function onFavorite(uri: string) {
// TODO: why does this need a dialog?
shouldShowSaveBookmarkDialog.set(true); shouldShowSaveBookmarkDialog.set(true);
saveBookmarkURI.set(uri); saveBookmarkURI.set(uri);
} }
@@ -169,11 +173,29 @@ export function plugin(client: PluginClient<Events, Methods>) {
bookmarks, bookmarks,
saveBookmarkURI, saveBookmarkURI,
shouldShowSaveBookmarkDialog, shouldShowSaveBookmarkDialog,
shouldShowURIErrorDialog,
requiredParameters,
appMatchPatterns, appMatchPatterns,
navigationEvents, navigationEvents,
currentURI, currentURI,
getAutoCompleteAppMatchPatterns(
query: string,
bookmarks: Map<string, Bookmark>,
appMatchPatterns: AppMatchPattern[],
limit: number,
): AppMatchPattern[] {
const q = query.toLowerCase();
const results: AppMatchPattern[] = [];
for (const item of appMatchPatterns) {
if (
!bookmarks.has(item.pattern) &&
(item.className.toLowerCase().includes(q) ||
item.pattern.toLowerCase().includes(q))
) {
results.push(item);
if (--limit < 1) break;
}
}
return results;
},
}; };
} }
@@ -185,8 +207,6 @@ export function Component() {
const shouldShowSaveBookmarkDialog = useValue( const shouldShowSaveBookmarkDialog = useValue(
instance.shouldShowSaveBookmarkDialog, instance.shouldShowSaveBookmarkDialog,
); );
const shouldShowURIErrorDialog = useValue(instance.shouldShowURIErrorDialog);
const requiredParameters = useValue(instance.requiredParameters);
const currentURI = useValue(instance.currentURI); const currentURI = useValue(instance.currentURI);
const navigationEvents = useValue(instance.navigationEvents); const navigationEvents = useValue(instance.navigationEvents);
@@ -227,15 +247,6 @@ export function Component() {
onSubmit={instance.addBookmark} onSubmit={instance.addBookmark}
onRemove={instance.removeBookmark} onRemove={instance.removeBookmark}
/> />
<RequiredParametersDialog
shouldShow={shouldShowURIErrorDialog}
onHide={() => {
instance.shouldShowURIErrorDialog.set(false);
}}
uri={currentURI}
requiredParameters={requiredParameters}
onSubmit={instance.navigateTo}
/>
</Layout.Container> </Layout.Container>
); );
} }

View File

@@ -15,6 +15,7 @@
"email": "beneloca@fb.com" "email": "beneloca@fb.com"
}, },
"peerDependencies": { "peerDependencies": {
"flipper-plugin": "0.64.0" "flipper-plugin": "0.64.0",
"antd": "*"
} }
} }