/ common / components / AddressFieldFactory / AddressFieldDropdown.tsx
AddressFieldDropdown.tsx
  1  import React from 'react';
  2  import { connect } from 'react-redux';
  3  
  4  import translate, { translateRaw } from 'translations';
  5  import { AppState } from 'features/reducers';
  6  import { transactionActions, transactionSelectors } from 'features/transaction';
  7  import { addressBookSelectors } from 'features/addressBook';
  8  import { walletSelectors } from 'features/wallet';
  9  import { Address, Identicon } from 'components/ui';
 10  import './AddressFieldDropdown.scss';
 11  
 12  interface Props {
 13    addressInput: string;
 14    dropdownThreshold?: number;
 15    labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
 16    recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>;
 17    onEntryClick(address: string): void;
 18  }
 19  
 20  interface State {
 21    activeIndex: number | null;
 22  }
 23  
 24  class AddressFieldDropdownClass extends React.Component<Props, State> {
 25    public state = {
 26      activeIndex: null
 27    };
 28  
 29    private exists: boolean = true;
 30  
 31    public componentDidMount() {
 32      window.addEventListener('keydown', this.handleKeyDown);
 33    }
 34  
 35    public componentWillUnmount() {
 36      window.removeEventListener('keydown', this.handleKeyDown);
 37      this.exists = false;
 38    }
 39  
 40    public render() {
 41      const { addressInput } = this.props;
 42  
 43      return (
 44        this.getIsVisible() && (
 45          <ul className="AddressFieldDropdown" role="listbox">
 46            {this.getFilteredLabels().length > 0 ? (
 47              this.renderDropdownItems()
 48            ) : (
 49              <li className="AddressFieldDropdown-dropdown-item AddressFieldDropdown-dropdown-item-no-match">
 50                <i className="fa fa-warning" /> {translate('NO_LABEL_FOUND_CONTAINING')} "{
 51                  addressInput
 52                }".
 53              </li>
 54            )}
 55          </ul>
 56        )
 57      );
 58    }
 59  
 60    private renderDropdownItems = () => {
 61      const { onEntryClick } = this.props;
 62      const { activeIndex } = this.state;
 63  
 64      return this.getFilteredLabels().map(
 65        ({ address, label }: { address: string; label: string }, index: number) => {
 66          const isActive = activeIndex === index;
 67          const className = `AddressFieldDropdown-dropdown-item ${
 68            isActive ? 'AddressFieldDropdown-dropdown-item--active' : ''
 69          }`;
 70  
 71          return (
 72            <li
 73              key={address}
 74              role="option"
 75              className={className}
 76              onClick={() => onEntryClick(address)}
 77              title={`${translateRaw('SEND_TO')}${label}`}
 78            >
 79              <div className="AddressFieldDropdown-dropdown-item-identicon">
 80                <Identicon address={address} />
 81              </div>
 82              <strong className="AddressFieldDropdown-dropdown-item-label">{label}</strong>
 83              <em className="AddressFieldDropdown-dropdown-item-address">
 84                <Address address={address} />
 85              </em>
 86            </li>
 87          );
 88        }
 89      );
 90    };
 91  
 92    private getFormattedRecentAddresses = (): { [label: string]: string } => {
 93      const { labelAddresses, recentAddresses } = this.props;
 94      // Hash existing entries by address for performance.
 95      const addressesInBook: { [address: string]: boolean } = Object.values(labelAddresses).reduce(
 96        (prev: { [address: string]: boolean }, next: string) => {
 97          prev[next] = true;
 98          return prev;
 99        },
100        {}
101      );
102      // Make recent addresses sequential.
103      let recentAddressCount: number = 0;
104      const addresses = recentAddresses.reduce((prev: { [label: string]: string }, next: string) => {
105        // Prevent duplication.
106        if (addressesInBook[next]) {
107          return prev;
108        }
109        prev[
110          translateRaw('RECENT_ADDRESS_NUMBER', { $number: (++recentAddressCount).toString() })
111        ] = next;
112  
113        return prev;
114      }, {});
115  
116      return addresses;
117    };
118  
119    private getFilteredLabels = () => {
120      const { addressInput, labelAddresses } = this.props;
121      const formattedRecentAddresses = this.getFormattedRecentAddresses();
122  
123      return Object.keys({ ...labelAddresses, ...formattedRecentAddresses })
124        .filter(label => label.toLowerCase().includes(addressInput))
125        .map(label => ({ address: labelAddresses[label] || formattedRecentAddresses[label], label }))
126        .slice(0, 5);
127    };
128  
129    private getIsVisible = () => {
130      const { addressInput, dropdownThreshold = 3 } = this.props;
131  
132      return addressInput.length >= dropdownThreshold && this.getFilteredLabels().length > 0;
133    };
134  
135    private setActiveIndex = (activeIndex: number | null) => this.setState({ activeIndex });
136  
137    private clearActiveIndex = () => this.setActiveIndex(null);
138  
139    //#region Keyboard Controls
140    private handleKeyDown = (e: KeyboardEvent) => {
141      if (this.getIsVisible()) {
142        switch (e.key) {
143          case 'Enter':
144            e.preventDefault();
145            return this.handleEnterKeyDown();
146          case 'ArrowUp':
147            e.preventDefault();
148            return this.handleUpArrowKeyDown();
149          case 'ArrowDown':
150            e.preventDefault();
151            return this.handleDownArrowKeyDown();
152          default:
153            return;
154        }
155      }
156    };
157  
158    private handleEnterKeyDown = () => {
159      const { onEntryClick } = this.props;
160      const { activeIndex } = this.state;
161  
162      if (activeIndex !== null) {
163        const filteredLabels = this.getFilteredLabels();
164  
165        filteredLabels.forEach(({ address }, index) => {
166          if (activeIndex === index) {
167            onEntryClick(address);
168          }
169        });
170  
171        if (this.exists) {
172          this.clearActiveIndex();
173        }
174      }
175    };
176  
177    private handleUpArrowKeyDown = () => {
178      const { activeIndex: previousActiveIndex } = this.state;
179      const filteredLabelCount = this.getFilteredLabels().length;
180  
181      let activeIndex =
182        previousActiveIndex === null ? filteredLabelCount - 1 : previousActiveIndex - 1;
183  
184      // Loop back to end
185      if (activeIndex < 0) {
186        activeIndex = filteredLabelCount - 1;
187      }
188  
189      this.setState({ activeIndex });
190    };
191  
192    private handleDownArrowKeyDown = () => {
193      const { activeIndex: previousActiveIndex } = this.state;
194      const filteredLabelCount = this.getFilteredLabels().length;
195  
196      let activeIndex = previousActiveIndex === null ? 0 : previousActiveIndex + 1;
197  
198      // Loop back to beginning
199      if (activeIndex >= filteredLabelCount) {
200        activeIndex = 0;
201      }
202  
203      this.setState({ activeIndex });
204    };
205    //#endregion Keyboard Controls
206  }
207  
208  //#region Uncontrolled
209  /**
210   * @desc The `onChangeOverride` prop needs to work
211   *  with actual events, but also needs a value to be directly passed in
212   *  occasionally. This interface allows us to skip all of the other FormEvent
213   *  properties and methods.
214   */
215  interface FakeFormEvent {
216    currentTarget: {
217      value: string;
218    };
219  }
220  
221  interface UncontrolledAddressFieldDropdownProps {
222    value: string;
223    labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
224    recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>;
225    dropdownThreshold?: number;
226    onChangeOverride(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void;
227  }
228  
229  /**
230   * @desc The uncontrolled dropdown changes the address input onClick,
231   *  as well as calls the onChange override, but does not update the `currentTo`
232   *  property in the Redux store.
233   */
234  function RawUncontrolledAddressFieldDropdown({
235    value,
236    onChangeOverride,
237    labelAddresses,
238    recentAddresses,
239    dropdownThreshold
240  }: UncontrolledAddressFieldDropdownProps) {
241    const onEntryClick = (address: string) => onChangeOverride({ currentTarget: { value: address } });
242  
243    return (
244      <AddressFieldDropdownClass
245        addressInput={value}
246        onEntryClick={onEntryClick}
247        labelAddresses={labelAddresses}
248        recentAddresses={recentAddresses}
249        dropdownThreshold={dropdownThreshold}
250      />
251    );
252  }
253  
254  const UncontrolledAddressFieldDropdown = connect((state: AppState) => ({
255    labelAddresses: addressBookSelectors.getLabelAddresses(state),
256    recentAddresses: walletSelectors.getRecentAddresses(state)
257  }))(RawUncontrolledAddressFieldDropdown);
258  //#endregion Uncontrolled
259  
260  //#region Controlled
261  interface ControlledAddressFieldDropdownProps {
262    currentTo: ReturnType<typeof transactionSelectors.getToRaw>;
263    labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
264    recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>;
265    setCurrentTo: transactionActions.TSetCurrentTo;
266    dropdownThreshold?: number;
267  }
268  
269  /**
270   * @desc The controlled dropdown connects directly to the Redux store,
271   *  modifying the `currentTo` property onChange.
272   */
273  function RawControlledAddressFieldDropdown({
274    currentTo,
275    labelAddresses,
276    recentAddresses,
277    setCurrentTo,
278    dropdownThreshold
279  }: ControlledAddressFieldDropdownProps) {
280    return (
281      <AddressFieldDropdownClass
282        addressInput={currentTo}
283        onEntryClick={setCurrentTo}
284        labelAddresses={labelAddresses}
285        recentAddresses={recentAddresses}
286        dropdownThreshold={dropdownThreshold}
287      />
288    );
289  }
290  
291  const ControlledAddressFieldDropdown = connect(
292    (state: AppState) => ({
293      currentTo: transactionSelectors.getToRaw(state),
294      labelAddresses: addressBookSelectors.getLabelAddresses(state),
295      recentAddresses: walletSelectors.getRecentAddresses(state)
296    }),
297    {
298      setCurrentTo: transactionActions.setCurrentTo
299    }
300  )(RawControlledAddressFieldDropdown);
301  //#endregion Controlled
302  
303  interface AddressFieldDropdownProps {
304    controlled: boolean;
305    value?: string;
306    dropdownThreshold?: number;
307    onChangeOverride?(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void;
308  }
309  
310  export default function AddressFieldDropdown({
311    controlled = true,
312    ...props
313  }: AddressFieldDropdownProps) {
314    const Dropdown = controlled ? ControlledAddressFieldDropdown : UncontrolledAddressFieldDropdown;
315  
316    return <Dropdown {...props} />;
317  }