Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add geosearch autocomplete. #192

Merged
merged 26 commits into from
Sep 23, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d23e771
Add geosearch autocomplete.
toolness Sep 21, 2018
a26b82e
Stylistic fixes.
toolness Sep 21, 2018
62746b6
Factor out geo-autocomplete.tsx.
toolness Sep 21, 2018
602b1fc
Factor out boroughs.ts.
toolness Sep 21, 2018
5e3113f
Move ProgressiveEnhancement into a separate file.
toolness Sep 22, 2018
6fc0f25
Refactor ProgressiveEnhancement to use render props.
toolness Sep 22, 2018
7042775
Make ProgressiveEnhancement an error boundary.
toolness Sep 22, 2018
65a7b88
Fallback to baseline if network errors occur in autocomplete.
toolness Sep 22, 2018
34cb01e
Show field errors in geo autocomplete.
toolness Sep 22, 2018
2cb52cd
Streamline how GeoAutocomplete works.
toolness Sep 22, 2018
0df83fb
Optionally log error when falling back to baseline.
toolness Sep 22, 2018
524ffa5
Refactor renderAutocomplete().
toolness Sep 22, 2018
7738e24
Refactor OnboardingStep1.renderForm().
toolness Sep 22, 2018
935a5b4
Make OnboardingStep1 stateless.
toolness Sep 22, 2018
ba534ab
Show loading spinner on autocomplete widget.
toolness Sep 22, 2018
a161e7c
Fire a change event at input elements instead of setting value.
toolness Sep 22, 2018
7b068c2
Add tests for GeoAutocomplete.
toolness Sep 22, 2018
28d1e8c
Add boroughs.test.ts.
toolness Sep 22, 2018
e8f1398
Add more geo-autocomplete tests.
toolness Sep 22, 2018
2cf581f
Add more tests, create MockFetch.
toolness Sep 22, 2018
7d73567
Factor out a pal.clickListItem().
toolness Sep 22, 2018
7370922
Move fake geo results to util.tsx.
toolness Sep 22, 2018
c6dfa5b
Add tests for progressive-enhancement.tsx.
toolness Sep 22, 2018
6b56d0f
Add docs.
toolness Sep 22, 2018
476a0c6
Add another test.
toolness Sep 22, 2018
40c570b
Add another test.
toolness Sep 22, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/lib/boroughs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { safeGetDjangoChoiceLabel } from "./common-data";

type BoroughDjangoChoice = [BoroughChoice, string];

export const BOROUGH_CHOICES = require('../../common-data/borough-choices.json') as BoroughDjangoChoice[];

export enum BoroughChoice {
MANHATTAN = 'MANHATTAN',
BRONX = 'BRONX',
BROOKLYN = 'BROOKLYN',
QUEENS = 'QUEENS',
STATEN_ISLAND = 'STATEN_ISLAND'
}

export function getBoroughLabel(borough: string): string|null {
return safeGetDjangoChoiceLabel(BOROUGH_CHOICES, borough);
}
1 change: 1 addition & 0 deletions frontend/lib/bulma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type BulmaClassName =
'is-text' |
'is-light' |
'has-dropdown' |
'control' |
'select' |
'input' |
'button' |
Expand Down
226 changes: 226 additions & 0 deletions frontend/lib/geo-autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import React from 'react';
import Downshift, { ControllerStateAndHelpers, DownshiftInterface } from 'downshift';
import classnames from 'classnames';
import autobind from 'autobind-decorator';
import { BoroughChoice, getBoroughLabel } from './boroughs';
import { WithFormFieldErrors, formatErrors } from './form-errors';
import { bulmaClasses } from './bulma';

/**
* The keys here were obtained experimentally, I'm not actually sure
* if/where they are formally specified.
*/
const BOROUGH_GID_TO_CHOICE: { [key: string]: BoroughChoice|undefined } = {
'whosonfirst:borough:1': BoroughChoice.MANHATTAN,
'whosonfirst:borough:2': BoroughChoice.BRONX,
'whosonfirst:borough:3': BoroughChoice.BROOKLYN,
'whosonfirst:borough:4': BoroughChoice.QUEENS,
'whosonfirst:borough:5': BoroughChoice.STATEN_ISLAND,
};

export interface GeoAutocompleteItem {
address: string;
borough: BoroughChoice;
};

interface GeoAutocompleteProps extends WithFormFieldErrors {
label: string;
initialValue?: GeoAutocompleteItem;
onChange: (item: GeoAutocompleteItem) => void;
onNetworkError: (err: Error) => void;
};

interface GeoSearchProperties {
/** e.g. "Brooklyn" */
borough: string;

/** e.g. "whosonfirst:borough:2" */
borough_gid: string;

/** e.g. "150" */
housenumber: string;

/** e.g. "150 COURT STREET" */
name: string;

/** e.g. "150 COURT STREET, Brooklyn, New York, NY, USA" */
label: string;
}

interface GeoSearchResults {
bbox: unknown;
features: {
geometry: unknown;
properties: GeoSearchProperties
}[];
}

interface GeoAutocompleteState {
isLoading: boolean;
results: GeoAutocompleteItem[];
}

/**
* The amount of ms we'll wait after the user pressed a key
* before we'll issue a network request to fetch autocompletion
* results.
*/
const AUTOCOMPLETE_KEY_THROTTLE_MS = 250;

/**
* For documentation about this endpoint, see:
*
* https://geosearch.planninglabs.nyc/docs/#autocomplete
*/
const GEO_AUTOCOMPLETE_URL = 'https://geosearch.planninglabs.nyc/v1/autocomplete';

/** The maximum number of autocomplete suggestions to show. */
const MAX_SUGGESTIONS = 5;

/**
* An address autocomplete field. This should only be used as a
* progressive enhancement, since it requires JavaScript and uses
* a third-party API that might become unavailable.
*/
export class GeoAutocomplete extends React.Component<GeoAutocompleteProps, GeoAutocompleteState> {
keyThrottleTimeout: number|null;
abortController: AbortController;

constructor(props: GeoAutocompleteProps) {
super(props);
this.state = {
isLoading: false,
results: []
};
this.keyThrottleTimeout = null;

// At the time of writing, AbortController isn't supported on older
// browsers like IE11, so this will throw. Yet another reason this
// component should only be used as a progressive enhancement!
this.abortController = new AbortController();
}

renderListItem(ds: ControllerStateAndHelpers<GeoAutocompleteItem>,
item: GeoAutocompleteItem,
index: number): JSX.Element {
const props = ds.getItemProps({
key: item.address + item.borough,
index,
item,
className: classnames({
'jf-autocomplete-is-highlighted': ds.highlightedIndex === index,
'jf-autocomplete-is-selected': ds.selectedItem === item
})
});

return (
<li {...props}>
{geoAutocompleteItemToString(item)}
</li>
);
}

renderAutocomplete(ds: ControllerStateAndHelpers<GeoAutocompleteItem>): JSX.Element {
const { errorHelp } = formatErrors(this.props);
const { results } = this.state;

return (
<div className="field jf-autocomplete-field">
<label className="label" {...ds.getLabelProps()}>{this.props.label}</label>
<div className={bulmaClasses('control', {
'is-loading': this.state.isLoading
})}>
<input className="input" {...ds.getInputProps()} />
<ul className={classnames({
'jf-autocomplete-open': ds.isOpen && results.length > 0
})} {...ds.getMenuProps()}>
{ds.isOpen && results.map((item, i) => this.renderListItem(ds, item, i))}
</ul>
</div>
{errorHelp}
</div>
);
}

resetSearchRequest() {
if (this.keyThrottleTimeout !== null) {
window.clearTimeout(this.keyThrottleTimeout);
this.keyThrottleTimeout = null;
}
this.abortController.abort();
this.abortController = new AbortController();
}

@autobind
handleFetchError(e: Error) {
if (e instanceof DOMException && e.name === 'AbortError') {
// Don't worry about it, the user just aborted the request.
} else {
// TODO: It would be nice if we could further differentiate
// between a "you aren't connected to the internet"
// error versus a "you issued a bad request" error, so that
// we could report the error if it's the latter.
this.props.onNetworkError(e);
}
}

@autobind
handleInputValueChange(value: string) {
this.resetSearchRequest();
if (value.length > 3 && value.indexOf(' ') > 0) {
this.setState({ isLoading: true });
this.keyThrottleTimeout = window.setTimeout(() => {
fetch(`${GEO_AUTOCOMPLETE_URL}?text=${encodeURIComponent(value)}`, {
signal: this.abortController.signal
}).then(response => response.json())
.then(results => this.setState({
isLoading: false,
results: geoSearchResultsToAutocompleteItems(results)
}))
.catch(this.handleFetchError);
}, AUTOCOMPLETE_KEY_THROTTLE_MS);
} else {
this.setState({ results: [], isLoading: false });
}
}

componentWillUnmount() {
this.resetSearchRequest();
}

render() {
const GeoAutocomplete = Downshift as DownshiftInterface<GeoAutocompleteItem>;

return (
<GeoAutocomplete
onChange={this.props.onChange}
onInputValueChange={this.handleInputValueChange}
defaultSelectedItem={this.props.initialValue}
itemToString={geoAutocompleteItemToString}
>
{(downshift) => this.renderAutocomplete(downshift)}
</GeoAutocomplete>
);
}
}

export function geoAutocompleteItemToString(item: GeoAutocompleteItem|null): string {
if (!item) return '';
return `${item.address}, ${getBoroughLabel(item.borough)}`;
}

export function geoSearchResultsToAutocompleteItems(results: GeoSearchResults): GeoAutocompleteItem[] {
return results.features.slice(0, MAX_SUGGESTIONS).map(feature => {
const { borough_gid } = feature.properties;
const borough = BOROUGH_GID_TO_CHOICE[borough_gid];

if (!borough) {
throw new Error(`No borough found for ${borough_gid}!`);
}

return {
address: feature.properties.name,
borough
}
});
}
80 changes: 53 additions & 27 deletions frontend/lib/pages/onboarding-step-1.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import autobind from 'autobind-decorator';
import { OnboardingStep1Mutation } from '../queries/OnboardingStep1Mutation';
import { assertNotNull } from '../util';
import { Modal, ModalLink } from '../modal';
import { DjangoChoices, getDjangoChoiceLabel } from '../common-data';
import { TextualFormField, SelectFormField } from '../form-fields';
import { NextButton } from '../buttons';
import { withAppContext, AppContextType } from '../app-context';
import { LogoutMutation } from '../queries/LogoutMutation';
import { bulmaClasses } from '../bulma';

const BOROUGH_CHOICES = require('../../../common-data/borough-choices.json') as DjangoChoices;
import { GeoAutocomplete } from '../geo-autocomplete';
import { getBoroughLabel, BOROUGH_CHOICES, BoroughChoice } from '../boroughs';
import { ProgressiveEnhancement, ProgressiveEnhancementContext } from '../progressive-enhancement';

const blankInitialState: OnboardingStep1Input = {
name: '',
Expand Down Expand Up @@ -48,9 +48,7 @@ export function Step1AddressModal(): JSX.Element {

export const ConfirmAddressModal = withAppContext((props: AppContextType): JSX.Element => {
const onboardingStep1 = props.session.onboardingStep1 || blankInitialState;
const borough = onboardingStep1.borough
? getDjangoChoiceLabel(BOROUGH_CHOICES, onboardingStep1.borough)
: '';
const borough = getBoroughLabel(onboardingStep1.borough) || '';

return (
<Modal title="Is this your address?" onCloseGoBack render={({close}) => (
Expand All @@ -64,17 +62,12 @@ export const ConfirmAddressModal = withAppContext((props: AppContextType): JSX.E
);
});

interface OnboardingStep1State {
isMounted: boolean;
interface OnboardingStep1Props {
disableProgressiveEnhancement?: boolean;
}

export default class OnboardingStep1 extends React.Component<{}, OnboardingStep1State> {
export default class OnboardingStep1 extends React.Component<OnboardingStep1Props> {
readonly cancelControlRef: React.RefObject<HTMLDivElement> = React.createRef();
readonly state = { isMounted: false };

componentDidMount() {
this.setState({ isMounted: true });
}

renderFormButtons(isLoading: boolean): JSX.Element {
return (
Expand All @@ -87,17 +80,48 @@ export default class OnboardingStep1 extends React.Component<{}, OnboardingStep1
);
}

@autobind
renderForm(ctx: FormContext<OnboardingStep1Input>): JSX.Element {
renderBaselineAddressFields(ctx: FormContext<OnboardingStep1Input>): JSX.Element {
return (
<React.Fragment>
<TextualFormField label="What is your full name?" {...ctx.fieldPropsFor('name')} />
<TextualFormField label="What is your address?" {...ctx.fieldPropsFor('address')} />
<SelectFormField
label="What is your borough?"
{...ctx.fieldPropsFor('borough')}
choices={BOROUGH_CHOICES}
/>
</React.Fragment>
);
}

renderEnhancedAddressField(ctx: FormContext<OnboardingStep1Input>, pe: ProgressiveEnhancementContext) {
const addressProps = ctx.fieldPropsFor('address');
const boroughProps = ctx.fieldPropsFor('borough');
let initialValue = addressProps.value && boroughProps.value
? { address: addressProps.value,
borough: boroughProps.value as BoroughChoice }
: undefined;

return <GeoAutocomplete
label="What is your address?"
initialValue={initialValue}
onChange={selection => {
addressProps.onChange(selection.address);
boroughProps.onChange(selection.borough);
}}
onNetworkError={pe.fallbackToBaseline}
errors={addressProps.errors || boroughProps.errors}
/>;
}

@autobind
renderForm(ctx: FormContext<OnboardingStep1Input>): JSX.Element {
return (
<React.Fragment>
<TextualFormField label="What is your full name?" {...ctx.fieldPropsFor('name')} />
<ProgressiveEnhancement
disabled={this.props.disableProgressiveEnhancement}
renderBaseline={() => this.renderBaselineAddressFields(ctx)}
renderEnhanced={(pe) => this.renderEnhancedAddressField(ctx, pe)} />
<TextualFormField label="What is your apartment number?" {...ctx.fieldPropsFor('aptNumber')} />
<ModalLink to={Routes.onboarding.step1AddressModal} component={Step1AddressModal} className="is-size-7">
Why do you need my address?
Expand Down Expand Up @@ -128,16 +152,18 @@ export default class OnboardingStep1 extends React.Component<{}, OnboardingStep1
// supports this via the <button> element's "form" attribute,
// but not all browsers support that, so we'll do something
// a bit clever/kludgy here to work around that.
<React.Fragment>
{this.state.isMounted && this.cancelControlRef.current
? ReactDOM.createPortal(
<button type="button" onClick={ctx.submit} className={bulmaClasses('button', 'is-light', {
'is-loading': ctx.isLoading
})}>Cancel signup</button>,
this.cancelControlRef.current
)
: <button type="submit" className="button is-light">Cancel signup</button>}
</React.Fragment>
<ProgressiveEnhancement
disabled={this.props.disableProgressiveEnhancement}
renderBaseline={() => <button type="submit" className="button is-light">Cancel signup</button>}
renderEnhanced={() => {
if (!this.cancelControlRef.current) throw new Error('cancelControlRef must exist!');
return ReactDOM.createPortal(
<button type="button" onClick={ctx.submit} className={bulmaClasses('button', 'is-light', {
'is-loading': ctx.isLoading
})}>Cancel signup</button>,
this.cancelControlRef.current
)
}} />
)}</SessionUpdatingFormSubmitter>
);
}
Expand Down
Loading