Skip to content

Commit

Permalink
refactor autocomplete input, now using useReducer (#14957)
Browse files Browse the repository at this point in the history
  • Loading branch information
erikdstock authored Dec 9, 2024
1 parent 34a5fee commit 174c372
Show file tree
Hide file tree
Showing 2 changed files with 163 additions and 107 deletions.
268 changes: 162 additions & 106 deletions src/Components/Address/AddressAutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { Address } from "Components/Address/utils"
import { useFeatureFlag } from "System/Hooks/useFeatureFlag"
import { getENV } from "Utils/getENV"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useReducer } from "react"
import { throttle, uniqBy } from "lodash"
import { useTracking } from "react-tracking"
import {
Expand All @@ -19,6 +19,10 @@ import {
SelectedItemFromAddressAutoCompletion,
} from "@artsy/cohesion"

// NOTE: Due to the format of this key (a long string of numbers that cannot be parsed as json)
// This key must be set in the env as a json string like SMARTY_EMBEDDED_KEY_JSON={ "key": "xxxxxxxxxxxxxxxxxx" }
const { key: API_KEY } = getENV("SMARTY_EMBEDDED_KEY_JSON") || { key: "" }

const THROTTLE_DELAY = 500

interface AutocompleteTrackingValues {
Expand Down Expand Up @@ -63,10 +67,75 @@ interface AddressAutocompleteSuggestion extends AutocompleteInputOptionType {
entries: number | null
}

interface ServiceAvailability {
type ServiceAvailability =
| {
enabled: true
apiKey: string
}
| {
enabled: false
}

interface State {
loading: boolean
enabled?: boolean
fetching: boolean
serviceAvailability: ServiceAvailability | null
providerSuggestions: ProviderSuggestion[]
}

const initialState: State = {
loading: true,
serviceAvailability: null,
fetching: false,
providerSuggestions: [],
}

type Action =
| { type: "SET_AVAILABILITY"; serviceAvailability: ServiceAvailability }
| { type: "FETCHING_STARTED" }
| { type: "FETCHING_COMPLETE" }
| { type: "SET_SUGGESTIONS"; providerSuggestions: ProviderSuggestion[] }
| { type: "RESET_SUGGESTIONS" }

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "SET_AVAILABILITY": {
return {
...state,
loading: false,
serviceAvailability: {
...action.serviceAvailability,
},
}
}
case "FETCHING_STARTED": {
return {
...state,
fetching: true,
}
}
case "FETCHING_COMPLETE": {
return {
...state,
fetching: false,
}
}
case "SET_SUGGESTIONS": {
return {
...state,
providerSuggestions: action.providerSuggestions,
}
}
case "RESET_SUGGESTIONS": {
return {
...initialState,
providerSuggestions: [],
}
}
default: {
return state
}
}
}

/**
Expand All @@ -92,25 +161,13 @@ export const AddressAutocompleteInput = ({
trackingValues,
...autocompleteProps
}: AddressAutocompleteInputProps) => {
const [providerSuggestions, setProviderSuggestions] = useState<
ProviderSuggestion[]
>([])
const [fetching, setFetching] = useState(false)
const [state, dispatch] = useReducer(reducer, initialState)

// NOTE: Due to the format of this key (a long string of numbers that cannot be parsed as json)
// This key must be set in the env as a json string like SMARTY_EMBEDDED_KEY_JSON={ "key": "xxxxxxxxxxxxxxxxxx" }
const { key: apiKey } = getENV("SMARTY_EMBEDDED_KEY_JSON") || { key: "" }
const { serviceAvailability, providerSuggestions } = state

const isUSAddress = address.country === "US"
const isFeatureFlagEnabled = !!useFeatureFlag("address_autocomplete_us")

const [serviceAvailability, setServiceAvailability] = useState<
ServiceAvailability
>({
loading: true,
fetching: false,
})

const { trackEvent } = useTracking()

const trackReceivedAutocompleteResult = (
Expand Down Expand Up @@ -145,114 +202,63 @@ export const AddressAutocompleteInput = ({
trackEvent(event)
}

// Load service availaibilty when country changes
useEffect(() => {
const isAPIKeyPresent = !!apiKey
const isAPIKeyPresent = !!API_KEY
const enabled = isAPIKeyPresent && isFeatureFlagEnabled && isUSAddress
if (enabled !== serviceAvailability.enabled) {
setServiceAvailability({
fetching,
loading: false,
enabled,
if (enabled !== serviceAvailability?.enabled) {
dispatch({
type: "SET_AVAILABILITY",
serviceAvailability: enabled
? { enabled: true, apiKey: API_KEY }
: { enabled: false },
})
}
}, [
apiKey,
isFeatureFlagEnabled,
isUSAddress,
fetching,
serviceAvailability.enabled,
])
}, [isFeatureFlagEnabled, isUSAddress, serviceAvailability])

// reset suggestions if the country changes
useEffect(() => {
if (providerSuggestions.length > 0 && !isUSAddress) {
setProviderSuggestions([])
dispatch({ type: "RESET_SUGGESTIONS" })
}
}, [isUSAddress, providerSuggestions.length])

const fetchSuggestions = useMemo(() => {
const throttledFetch = throttle(
async ({ search, selected }: { search: string; selected?: string }) => {
const params = {
key: apiKey,
search: search,
}

if (selected) {
params["selected"] = selected
}

if (!apiKey) return null
let url =
"https://us-autocomplete-pro.api.smarty.com/lookup?" +
new URLSearchParams(params).toString()

setFetching(true)
const response = await fetch(url)
setFetching(false)
const json = await response.json()
return json
},
THROTTLE_DELAY,
{
leading: true,
trailing: true,
}
)
return throttledFetch
}, [apiKey])

const buildAddressText = useCallback(
(suggestion: ProviderSuggestion): string => {
let buildingAddress = suggestion.street_line
if (suggestion.secondary) buildingAddress += ` ${suggestion.secondary}`

return [
`${buildingAddress}, ${suggestion.city}`,
suggestion.state,
suggestion.zipcode,
].join(" ")
},
[]
)

const filterSecondarySuggestions = useCallback(
(suggestions: ProviderSuggestion[]) => {
const noSecondaryData = suggestions.map(
({ secondary, ...suggestion }) => ({
...suggestion,
secondary: "",
})
)
return uniqBy(noSecondaryData, (suggestion: ProviderSuggestion) =>
buildAddressText(suggestion)
)
},
[buildAddressText]
)

const fetchForAutocomplete = useCallback(
// these are the parameters to the Smarty API call
async ({ search, selected }: { search: string; selected?: string }) => {
if (!serviceAvailability.enabled) return
if (!serviceAvailability?.enabled) return

if (search.length < 3) {
setProviderSuggestions([])
dispatch({ type: "RESET_SUGGESTIONS" })
return
}

try {
const response = await fetchSuggestions({ search, selected })
dispatch({ type: "FETCHING_STARTED" })

const response = await fetchSuggestionsWithThrottle({
search,
selected,
apiKey: serviceAvailability.apiKey,
})

dispatch({ type: "FETCHING_COMPLETE" })
const finalSuggestions = filterSecondarySuggestions(
response.suggestions
)

setProviderSuggestions(finalSuggestions.slice(0, 5))
dispatch({
type: "SET_SUGGESTIONS",
providerSuggestions: finalSuggestions.slice(0, 5),
})
} catch (e) {
console.error(e)
dispatch({ type: "RESET_SUGGESTIONS" })
dispatch({ type: "FETCHING_COMPLETE" })
// Disable autocomplete into some error state?
}
},
[fetchSuggestions, filterSecondarySuggestions, serviceAvailability.enabled]
[serviceAvailability]
)

const autocompleteOptions = providerSuggestions.map(
Expand All @@ -276,16 +282,15 @@ export const AddressAutocompleteInput = ({
)

const definitelyDisabled =
disableAutocomplete ||
(!serviceAvailability.loading && !serviceAvailability.enabled)
disableAutocomplete || (!state.loading && !serviceAvailability?.enabled)

const previousOptions = usePrevious(autocompleteOptions)
const serializedOptions = JSON.stringify(autocompleteOptions)
const serializedPreviousOptions = JSON.stringify(previousOptions)

useEffect(() => {
if (
serviceAvailability.enabled &&
serviceAvailability?.enabled &&
serializedOptions !== serializedPreviousOptions
) {
trackReceivedAutocompleteResult(
Expand All @@ -294,11 +299,7 @@ export const AddressAutocompleteInput = ({
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
serviceAvailability.enabled,
serializedOptions,
serializedPreviousOptions,
])
}, [serviceAvailability, serializedOptions, serializedPreviousOptions])

if (definitelyDisabled) {
return (
Expand All @@ -324,7 +325,7 @@ export const AddressAutocompleteInput = ({
id={id}
name={name}
placeholder={placeholder}
loading={serviceAvailability.loading || serviceAvailability.fetching}
loading={state.loading || state.fetching}
options={autocompleteOptions}
title="Address line 1"
value={value}
Expand All @@ -345,3 +346,58 @@ export const AddressAutocompleteInput = ({
/>
)
}

const fetchSuggestionsWithThrottle = throttle(
async ({
search,
selected,
apiKey,
}: {
search: string
selected?: string
apiKey: string
}) => {
const params = {
key: apiKey,
search: search,
}

if (selected) {
params["selected"] = selected
}

let url =
"https://us-autocomplete-pro.api.smarty.com/lookup?" +
new URLSearchParams(params).toString()

const response = await fetch(url)
const json = await response.json()
return json
},
THROTTLE_DELAY,
{
leading: true,
trailing: true,
}
)

const buildAddressText = (suggestion: ProviderSuggestion): string => {
let buildingAddress = suggestion.street_line
if (suggestion.secondary) buildingAddress += ` ${suggestion.secondary}`

return [
`${buildingAddress}, ${suggestion.city}`,
suggestion.state,
suggestion.zipcode,
].join(" ")
}

const filterSecondarySuggestions = (suggestions: ProviderSuggestion[]) => {
const noSecondaryData = suggestions.map(({ secondary, ...suggestion }) => ({
...suggestion,
secondary: "",
}))
return uniqBy(noSecondaryData, (suggestion: ProviderSuggestion) =>
buildAddressText(suggestion)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe("AddressAutocompleteInput", () => {
const line1Input = screen.getByPlaceholderText("Autocomplete input")
await userEvent.type(line1Input, "401 Broadway")

const clearButton = screen.getByLabelText("Clear input")
const clearButton = await screen.findByLabelText("Clear input")
await userEvent.click(clearButton)

expect(mockOnClear).toHaveBeenCalledTimes(1)
Expand Down

0 comments on commit 174c372

Please sign in to comment.