Skip to content

Commit 4f77583

Browse files
authored
ORV2-3085 - Integrate Geocoder API into FE (#1835)
1 parent d542222 commit 4f77583

File tree

18 files changed

+480
-28
lines changed

18 files changed

+480
-28
lines changed

charts/onroutebc/values.yaml

+5-1
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,17 @@ frontend:
4747
"VITE_SITEMINDER_LOG_OFF_URL":"$SITEMINDER_LOG_OFF_URL",
4848
"VITE_PR_NUM":"$PR_NUM",
4949
"VITE_RELEASE_NUM":"$RELEASE_NUM",
50+
"VITE_BC_GEOCODER_CLIENT_ID":"$BC_GEOCODER_CLIENT_ID",
51+
"VITE_BC_GEOCODER_API_KEY":"$BC_GEOCODER_API_KEY",
52+
"VITE_BC_GEOCODER_API_URL":"$BC_GEOCODER_API_URL",
5053
};
5154
})();
5255
containers:
5356
- name: frontend
5457
command:
5558
- "sh"
5659
- "-c"
57-
- "source /vault/secrets/keycloak-{{.Values.global.vault.zone}} && envsubst < /usr/share/nginx/html/config/config.js.template > /usr/share/nginx/html/config/config.js && nginx -g 'daemon off;'"
60+
- "source /vault/secrets/keycloak-{{.Values.global.vault.zone}} && source /vault/secrets/geocoder-{{.Values.global.vault.zone}} && envsubst < /usr/share/nginx/html/config/config.js.template > /usr/share/nginx/html/config/config.js && nginx -g 'daemon off;'"
5861
registry: '{{ .Values.global.registry }}'
5962
repository: '{{ .Values.global.repository }}' # example, it includes registry and repository
6063
image: frontend
@@ -158,6 +161,7 @@ frontend:
158161
license: "{{.Values.global.license}}"
159162
secretPaths:
160163
- "keycloak-{{tpl $.Values.vault.zone $}}"
164+
- "geocoder-{{tpl $.Values.vault.zone $}}"
161165
zone: "{{.Values.global.vault.zone}}"
162166
volumes:
163167
- name: config

docker-compose.yml

+3
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,9 @@ services:
209209
VITE_KEYCLOAK_ISSUER_URL: ${VITE_KEYCLOAK_ISSUER_URL}
210210
VITE_KEYCLOAK_AUDIENCE: ${VITE_KEYCLOAK_AUDIENCE}
211211
VITE_SITEMINDER_LOG_OFF_URL: ${VITE_SITEMINDER_LOG_OFF_URL}
212+
VITE_BC_GEOCODER_CLIENT_ID: ${VITE_BC_GEOCODER_CLIENT_ID}
213+
VITE_BC_GEOCODER_API_KEY: ${VITE_BC_GEOCODER_API_KEY}
214+
VITE_BC_GEOCODER_API_URL: ${VITE_BC_GEOCODER_API_URL}
212215
healthcheck:
213216
test: "curl --silent --fail http://localhost:3000/ > /dev/null || exit 1"
214217
interval: 1m30s

frontend/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ VITE_POLICY_URL=http://localhost:5002
2323
VITE_KEYCLOAK_ISSUER_URL=
2424
VITE_KEYCLOAK_AUDIENCE=
2525
VITE_SITEMINDER_LOG_OFF_URL=
26+
VITE_BC_GEOCODER_CLIENT_ID=
27+
VITE_BC_GEOCODER_API_KEY=
28+
VITE_BC_GEOCODER_API_URL=https://geocoder.api.gov.bc.ca/addresses.json
2629
```
2730

2831
### Prerequisites

frontend/public/config/config.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@ const envConfig = (() => {
2424
VITE_FRONTEND_PR_NUM: "",
2525
VITE_POLICY_URL: "",
2626
VITE_RELEASE_NUM: "",
27+
VITE_BC_GEOCODER_CLIENT_ID: "",
28+
VITE_BC_GEOCODER_API_KEY: "",
29+
VITE_BC_GEOCODER_API_URL: "",
2730
};
28-
})();
31+
})();

frontend/src/common/apiManager/endpoints/endpoints.ts

+9
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,12 @@ export const VEHICLES_URL =
33

44
export const POLICY_URL =
55
import.meta.env.VITE_POLICY_URL || envConfig.VITE_POLICY_URL;
6+
7+
export const GEOCODER_URL =
8+
import.meta.env.VITE_GEOCODER_URL || envConfig.VITE_BC_GEOCODER_API_URL;
9+
10+
export const GEOCODER_API_KEY =
11+
import.meta.env.VITE_GEOCODER_API_KEY || envConfig.VITE_BC_GEOCODER_API_KEY;
12+
13+
export const GEOCODER_CLIENT_ID =
14+
import.meta.env.VITE_GEOCODER_CLIENT_ID || envConfig.VITE_BC_GEOCODER_CLIENT_ID;
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import axios from "axios";
2+
3+
import { GEOCODER_API_KEY, GEOCODER_URL } from "./endpoints/endpoints";
4+
import { GeocoderAddressResponse, GeocoderQueryOptions } from "../types/geocoder";
5+
import { getDefaultRequiredVal } from "../helpers/util";
6+
import {
7+
DEFAULT_AUTOCOMPLETE,
8+
DEFAULT_BRIEF,
9+
DEFAULT_ECHO,
10+
DEFAULT_LOCATION_DESCRIPTOR,
11+
DEFAULT_MAX_RESULTS,
12+
DEFAULT_MIN_SCORE,
13+
DEFAULT_OUTPUT_SRS,
14+
} from "../constants/geocoder";
15+
16+
/**
17+
* A HTTP GET Request for Geocoder API.
18+
* @param address The address of the location to search for.
19+
* @returns The search results returned from the Geocoder API.
20+
*/
21+
export const httpGETGeocoder = async (
22+
address: string,
23+
options?: GeocoderQueryOptions,
24+
): Promise<GeocoderAddressResponse> => {
25+
const queryParams = new URLSearchParams();
26+
queryParams.set(
27+
"autoComplete",
28+
`${getDefaultRequiredVal(DEFAULT_AUTOCOMPLETE, options?.autoComplete)}`,
29+
);
30+
31+
queryParams.set("addressString", address);
32+
queryParams.set(
33+
"maxResults",
34+
`${getDefaultRequiredVal(DEFAULT_MAX_RESULTS, options?.maxResults)}`,
35+
);
36+
37+
queryParams.set(
38+
"minScore",
39+
`${getDefaultRequiredVal(DEFAULT_MIN_SCORE, options?.minScore)}`,
40+
);
41+
42+
queryParams.set("echo", `${getDefaultRequiredVal(DEFAULT_ECHO, options?.echo)}`);
43+
queryParams.set("brief", `${getDefaultRequiredVal(DEFAULT_BRIEF, options?.brief)}`);
44+
queryParams.set(
45+
"locationDescriptor",
46+
getDefaultRequiredVal(DEFAULT_LOCATION_DESCRIPTOR, options?.locationDescriptor),
47+
);
48+
49+
queryParams.set(
50+
"outputSRS",
51+
`${getDefaultRequiredVal(DEFAULT_OUTPUT_SRS, options?.outputSRS)}`,
52+
);
53+
54+
const response = await axios.get(
55+
`${GEOCODER_URL}?${queryParams.toString()}`,
56+
{
57+
headers: {
58+
apikey: GEOCODER_API_KEY,
59+
},
60+
},
61+
);
62+
63+
return response.data;
64+
};

frontend/src/common/apiManager/httpRequestHandler.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import axios from "axios";
22
import { v4 as uuidv4 } from "uuid";
3+
4+
import { Nullable, RequiredOrNull } from "../types/common";
5+
import { GEOCODER_URL } from "./endpoints/endpoints";
36
import {
47
applyWhenNotNullable,
58
getDefaultNullableVal,
69
getDefaultRequiredVal,
710
} from "../helpers/util";
8-
import { Nullable, RequiredOrNull } from "../types/common";
911

1012
// Request interceptor to add a correlationId to the header.
1113
axios.interceptors.request.use(
@@ -17,6 +19,12 @@ axios.interceptors.request.use(
1719
function (error) {
1820
console.log("Unable to make a request:", error);
1921
},
22+
{
23+
runWhen: (config) => {
24+
// Add exception to not use interceptor when requesting Geocoder URL
25+
return Boolean(!config.url?.startsWith(GEOCODER_URL));
26+
},
27+
},
2028
);
2129

2230
// Add environment variables to get the full key.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useEffect, useState } from "react";
2+
import {
3+
MenuItem,
4+
AutocompleteProps as MuiAutocompleteProps,
5+
} from "@mui/material";
6+
7+
import { Autocomplete, AutocompleteProps } from "./subFormComponents/Autocomplete";
8+
import { useGeocoder } from "../../hooks/useGeocoder";
9+
import { useMemoizedArray } from "../../hooks/useMemoizedArray";
10+
import { getDefaultRequiredVal } from "../../helpers/util";
11+
import { Nullable } from "../../types/common";
12+
import { DEBOUNCE_DURATION, MIN_SEARCH_LENGTH } from "../../constants/geocoder";
13+
14+
export interface GeocoderInputProps<
15+
DisableClearable extends boolean | undefined,
16+
ChipComponent extends React.ElementType,
17+
> extends Omit<
18+
AutocompleteProps<
19+
string,
20+
false,
21+
DisableClearable,
22+
true,
23+
ChipComponent
24+
>,
25+
"autocompleteProps"
26+
> {
27+
autocompleteProps?: Omit<
28+
MuiAutocompleteProps<string, false, DisableClearable, true, ChipComponent>,
29+
"renderInput"
30+
| "options"
31+
| "renderOption"
32+
| "isOptionEqualToValue"
33+
| "value"
34+
| "onChange"
35+
| "onInputChange"
36+
>;
37+
selectedAddress?: Nullable<string>;
38+
onSelectAddress?: (address: string) => void;
39+
onAddressSearchChange?: (searchString: string) => void;
40+
}
41+
42+
export const GeocoderInput = <
43+
DisableClearable extends boolean | undefined = false,
44+
ChipComponent extends React.ElementType = "div",
45+
>(props: GeocoderInputProps<
46+
DisableClearable,
47+
ChipComponent
48+
>) => {
49+
const {
50+
autocompleteProps,
51+
label,
52+
classes,
53+
helperText,
54+
onSelectAddress,
55+
onAddressSearchChange,
56+
} = props;
57+
58+
// Already previously selected address to search for, or empty if it doesn't exist
59+
const selectedAddress = getDefaultRequiredVal("", props.selectedAddress);
60+
61+
// This is the search string that appears in the input textfield
62+
const [searchString, setSearchString] = useState<string>(selectedAddress);
63+
64+
// This is the same as the searchString, except it's only set after a debounce period
65+
const [debouncedSearchString, setDebouncedSearchString] = useState<string>(selectedAddress);
66+
const [isOpen, setIsOpen] = useState<boolean>(false);
67+
68+
useEffect(() => {
69+
setSearchString(selectedAddress);
70+
}, [selectedAddress]);
71+
72+
useEffect(() => {
73+
// We need to debounce the search string to throttle the Geocoder API calls
74+
const debounceTimeout = setTimeout(() => {
75+
setDebouncedSearchString(searchString);
76+
}, DEBOUNCE_DURATION);
77+
78+
return () => clearTimeout(debounceTimeout);
79+
}, [searchString]);
80+
81+
// Search for addresses based on the debounced search string
82+
const { data: geocoderResults, isLoading } = useGeocoder({
83+
address: debouncedSearchString,
84+
enableSearch: isOpen && (debouncedSearchString.trim().length >= MIN_SEARCH_LENGTH),
85+
});
86+
87+
const addressSuggestions = useMemoizedArray(
88+
getDefaultRequiredVal(
89+
[],
90+
geocoderResults?.features?.map(({ properties }) => properties.fullAddress),
91+
),
92+
(result) => result,
93+
(result1, result2) => result1 === result2,
94+
);
95+
96+
const handleOpen = () => {
97+
setIsOpen(true);
98+
};
99+
100+
const handleClose = () => {
101+
setIsOpen(false);
102+
if (!searchString) {
103+
// If inputted search string is empty when exiting geocoder input,
104+
// set selected address to be empty
105+
onSelectAddress?.("");
106+
} else if (searchString !== selectedAddress) {
107+
// If upon exiting geocoder, the inputted search string is partial,
108+
// and none of the address options where selected,
109+
// or if no available options to select due to search string being invalid
110+
// then set selected address to be previously selected address
111+
setSearchString(selectedAddress);
112+
}
113+
};
114+
115+
return (
116+
<Autocomplete
117+
label={label}
118+
classes={classes}
119+
helperText={helperText}
120+
autocompleteProps={{
121+
...autocompleteProps,
122+
freeSolo: true,
123+
options: addressSuggestions,
124+
filterOptions: (options) => options,
125+
renderOption: (props, option) => (
126+
<MenuItem {...props} key={option} value={option}>
127+
{option}
128+
</MenuItem>
129+
),
130+
isOptionEqualToValue: (option, value) =>
131+
option === value,
132+
open: isOpen,
133+
onOpen: handleOpen,
134+
onClose: handleClose,
135+
value: selectedAddress,
136+
inputValue: searchString,
137+
onChange: (_, value) => {
138+
if (!value) {
139+
onSelectAddress?.("");
140+
} else {
141+
onSelectAddress?.(value);
142+
}
143+
},
144+
onInputChange: (_, value) => {
145+
setSearchString(value);
146+
onAddressSearchChange?.(value);
147+
},
148+
loading: isLoading,
149+
}}
150+
/>
151+
);
152+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const DEFAULT_MAX_RESULTS = 7;
2+
export const DEFAULT_MIN_SCORE = 50;
3+
export const DEFAULT_ECHO = false;
4+
export const DEFAULT_BRIEF = true;
5+
export const DEFAULT_AUTOCOMPLETE = true;
6+
export const DEFAULT_LOCATION_DESCRIPTOR = "accessPoint";
7+
export const DEFAULT_OUTPUT_SRS = 3857;
8+
export const DEBOUNCE_DURATION = 500;
9+
export const MIN_SEARCH_LENGTH = 3;
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
3+
import { httpGETGeocoder } from "../apiManager/geocoder";
4+
import { GeocoderQueryOptions } from "../types/geocoder";
5+
6+
const QUERY_KEYS = {
7+
ADDRESS: (
8+
address: string,
9+
queryOptions?: GeocoderQueryOptions,
10+
) => ["address", address, queryOptions] as const,
11+
} as const;
12+
13+
export const useGeocoder = ({
14+
address,
15+
enableSearch,
16+
queryOptions,
17+
}: {
18+
address: string;
19+
enableSearch: boolean;
20+
queryOptions?: GeocoderQueryOptions;
21+
}) => {
22+
return useQuery({
23+
queryKey: QUERY_KEYS.ADDRESS(address, queryOptions),
24+
queryFn: () => httpGETGeocoder(address, queryOptions),
25+
retry: false,
26+
refetchOnWindowFocus: false, // prevents unnecessary queries
27+
enabled: enableSearch,
28+
});
29+
};

0 commit comments

Comments
 (0)