diff --git a/CHANGELOG.md b/CHANGELOG.md index 87868248b..45ab373af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Not released +- GeocoderWidget support for LDS queries in CARTO 3 [#387](https://github.com/CartoDB/carto-react/pull/387) + ## 1.3 ### 1.3.0-alpha.6 (2022-04-27) diff --git a/packages/react-api/__tests__/api/lds.test.js b/packages/react-api/__tests__/api/lds.test.js new file mode 100644 index 000000000..f047ac3f0 --- /dev/null +++ b/packages/react-api/__tests__/api/lds.test.js @@ -0,0 +1,49 @@ +import { API_VERSIONS } from '@deck.gl/carto'; +import { ldsGeocode } from '../../src/api/lds'; + +const sampleCredentialsV3 = { + apiVersion: API_VERSIONS.V3, + accessToken: 'thisIsTheTestToken', + apiBaseUrl: 'https://api.com/' +}; + +const someCoordinates = { + latitude: 42.360278, + longitude: -71.057778 +}; + +describe('lds', () => { + describe('ldsDecode', () => { + test('should send proper requests', async () => { + const fetchMock = (global.fetch = jest.fn().mockImplementation(async () => { + return { + ok: true, + json: async () => [{ value: [someCoordinates] }] + }; + })); + + const abortController = new AbortController(); + expect( + await ldsGeocode({ + credentials: sampleCredentialsV3, + address: 'boston', + country: 'US', + limit: 4, + opts: { + abortController: abortController + } + }) + ).toEqual([someCoordinates]); + + expect(fetchMock).toBeCalledWith( + 'https://api.com//v3/lds/geocoding/geocode?address=boston&country=US&limit=4', + { + headers: { + Authorization: `Bearer ${sampleCredentialsV3.accessToken}` + }, + signal: abortController.signal + } + ); + }); + }); +}); diff --git a/packages/react-api/src/api/SQL.js b/packages/react-api/src/api/SQL.js index 34e5f224b..c6478a15b 100644 --- a/packages/react-api/src/api/SQL.js +++ b/packages/react-api/src/api/SQL.js @@ -2,9 +2,10 @@ import { encodeParameter, getRequest, postRequest } from '@carto/react-core'; import { REQUEST_GET_MAX_URL_LENGTH } from '@carto/react-core'; import { API_VERSIONS } from '@deck.gl/carto'; -import { dealWithApiError, generateApiUrl } from './common'; +import { dealWithApiError } from './common'; const CLIENT = 'carto-react'; +const DEFAULT_USER_COMPONENT_IN_URL = '{user}'; /** * Executes a SQL query @@ -100,3 +101,47 @@ function createRequest({ credentials, connection, query, opts = {} }) { }); return postRequest(postUrl, rawParams, requestOpts); } + +/** + * Generate a valid API url for a request + */ +export function generateApiUrl({ credentials, connection, parameters }) { + const { apiVersion = API_VERSIONS.V2, apiBaseUrl } = credentials; + + let url; + switch (apiVersion) { + case API_VERSIONS.V1: + case API_VERSIONS.V2: + url = `${sqlApiUrl(credentials)}api/v2/sql`; + break; + + case API_VERSIONS.V3: + url = `${apiBaseUrl}/v3/sql/${connection}/query`; + break; + + default: + throw new Error(`Unknown apiVersion ${apiVersion}`); + } + + if (!parameters) { + return url; + } + + return `${url}?${parameters.join('&')}`; +} + +/** + * Prepare a url valid for the specified user, from the serverUrlTemplate + */ +function sqlApiUrl(credentials) { + let url = credentials.serverUrlTemplate.replace( + DEFAULT_USER_COMPONENT_IN_URL, + credentials.username + ); + + if (!url.endsWith('/')) { + url += '/'; + } + + return url; +} diff --git a/packages/react-api/src/api/common.js b/packages/react-api/src/api/common.js index fe6e8531d..c9cdd55a2 100644 --- a/packages/react-api/src/api/common.js +++ b/packages/react-api/src/api/common.js @@ -1,7 +1,3 @@ -import { API_VERSIONS } from '@deck.gl/carto'; - -const DEFAULT_USER_COMPONENT_IN_URL = '{user}'; - /** * Return more descriptive error from API */ @@ -12,50 +8,6 @@ export function dealWithApiError({ credentials, response, data }) { case 403: throw new Error('Forbidden access to the requested data'); default: - throw new Error(`${JSON.stringify(data.hint || data.error?.[0])}`); + throw new Error(`${JSON.stringify(data?.hint || data.error?.[0])}`); } } - -/** - * Generate a valid API url for a request - */ -export function generateApiUrl({ credentials, connection, parameters }) { - const { apiVersion = API_VERSIONS.V2, apiBaseUrl } = credentials; - - let url; - switch (apiVersion) { - case API_VERSIONS.V1: - case API_VERSIONS.V2: - url = `${sqlApiUrl(credentials)}api/v2/sql`; - break; - - case API_VERSIONS.V3: - url = `${apiBaseUrl}/v3/sql/${connection}/query`; - break; - - default: - throw new Error(`Unknown apiVersion ${apiVersion}`); - } - - if (!parameters) { - return url; - } - - return `${url}?${parameters.join('&')}`; -} - -/** - * Prepare a url valid for the specified user, from the serverUrlTemplate - */ -function sqlApiUrl(credentials) { - let url = credentials.serverUrlTemplate.replace( - DEFAULT_USER_COMPONENT_IN_URL, - credentials.username - ); - - if (!url.endsWith('/')) { - url += '/'; - } - - return url; -} diff --git a/packages/react-api/src/api/lds.js b/packages/react-api/src/api/lds.js new file mode 100644 index 000000000..f79b50399 --- /dev/null +++ b/packages/react-api/src/api/lds.js @@ -0,0 +1,58 @@ +import { dealWithApiError } from './common'; + +/** + * Execute a LDS geocoding service geocode request. + * + * @param { object } props + * @param { string } props.address - searched address to be executed + * @param { string= } props.country - optional, limit search scope to country as ISO-3166 alpha-2 code, example, ES, DE + * @param { number= } props.limit - optional, limit of ansewers + * @param { Object } props.credentials - CARTO user credentials + * @param { string } props.credentials.accessToken - CARTO 3 access token + * @param { string } props.credentials.apiBaseUrl - CARTO 3 api server URL + * @param { Object= } props.opts - Additional options for the HTTP request + */ +export async function ldsGeocode({ credentials, address, country, limit, opts }) { + if (!credentials || !credentials.apiBaseUrl || !credentials.accessToken) { + throw new Error('ldsGeocode: Missing or bad credentials provided'); + } + if (!address) { + throw new Error('ldsGeocode: No address provided'); + } + + const url = new URL(`${credentials.apiBaseUrl}/v3/lds/geocoding/geocode`); + url.searchParams.set('address', address); + if (country) { + url.searchParams.set('country', country); + } + if (limit) { + url.searchParams.set('limit', String(limit)); + } + + const { abortController, ...otherOptions } = opts || {}; + + let response; + let data; + try { + response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${credentials.accessToken}` + }, + signal: abortController?.signal, + ...otherOptions + }); + data = await response.json(); + } catch (error) { + if (error.name === 'AbortError') throw error; + + throw new Error(`Failed to connect to LDS API: ${error}`); + } + if (Array.isArray(data)) { + data = data[0]; + } + if (!response.ok) { + dealWithApiError({ credentials, response, data }); + } + + return data.value; +} diff --git a/packages/react-api/src/index.d.ts b/packages/react-api/src/index.d.ts index cbadce303..f6c210854 100644 --- a/packages/react-api/src/index.d.ts +++ b/packages/react-api/src/index.d.ts @@ -1,4 +1,5 @@ export { executeSQL } from './api/SQL'; +export { ldsGeocode } from './api/lds'; export { default as useCartoLayerProps } from './hooks/useCartoLayerProps'; diff --git a/packages/react-api/src/index.js b/packages/react-api/src/index.js index 69658d111..bcfa6d009 100644 --- a/packages/react-api/src/index.js +++ b/packages/react-api/src/index.js @@ -1,4 +1,5 @@ export { executeSQL } from './api/SQL'; +export { ldsGeocode } from './api/lds'; export { default as useCartoLayerProps } from './hooks/useCartoLayerProps'; diff --git a/packages/react-redux/src/slices/oauthSlice.js b/packages/react-redux/src/slices/oauthSlice.js index 42ef5d3d8..5fccc37a0 100644 --- a/packages/react-redux/src/slices/oauthSlice.js +++ b/packages/react-redux/src/slices/oauthSlice.js @@ -92,8 +92,8 @@ export const setTokenAndUserInfoAsync = createAsyncThunk( export const logout = () => ({ type: 'oauth/logout', payload: {} }); // Get the credentials, from curren token & userInfo -const selectToken = (state) => state.oauth.token; -const selectUserInfo = (state) => state.oauth.userInfo; +const selectToken = (state) => state.oauth?.token; +const selectUserInfo = (state) => state.oauth?.userInfo; /** * Selector to fetch the current OAuth credentials from the store diff --git a/packages/react-redux/src/types.d.ts b/packages/react-redux/src/types.d.ts index 65e95e74a..6756ac839 100644 --- a/packages/react-redux/src/types.d.ts +++ b/packages/react-redux/src/types.d.ts @@ -39,7 +39,7 @@ export type InitialCartoState = InitialCarto2State | InitialCarto3State; export type CartoState = { viewport: Viewport | undefined, - geocoderResult: object | null, + geocoderResult: Record | null, error: null, // TODO: remove from state? layers: { [key: string]: string }, dataSources: { [key: string]: string }, diff --git a/packages/react-widgets/__tests__/models/GeocodingModel.test.js b/packages/react-widgets/__tests__/models/GeocodingModel.test.js new file mode 100644 index 000000000..c567851fc --- /dev/null +++ b/packages/react-widgets/__tests__/models/GeocodingModel.test.js @@ -0,0 +1,68 @@ +import { geocodeStreetPoint } from '../../src/models/GeocodingModel'; +import { API_VERSIONS } from '@deck.gl/carto'; +import { ldsGeocode, executeSQL } from '@carto/react-api/'; + +const sampleCredentialsV2 = { + apiVersion: API_VERSIONS.V2 +}; +const sampleCredentialsV3 = { + apiVersion: API_VERSIONS.V3, + accessToken: 'token', + apiBaseUrl: 'https://api.com/' +}; + +const bostonCoordinates = { + latitude: 42.360278, + longitude: -71.057778 +}; + +jest.mock('@carto/react-api', () => ({ + executeSQL: jest.fn().mockImplementation(async () => global.executeSqlMockResult), + ldsGeocode: jest.fn().mockImplementation(async () => global.ldsGeocodeMockResult) +})); + +describe('geocodeStreetPoint', () => { + afterEach(() => { + delete global.executeSqlMockResult; + delete global.ldsGeocodeMockResult; + }); + test('executes correct SQL for V2 credentials', async () => { + global.executeSqlMockResult = [ + { + geometry: JSON.stringify({ + coordinates: [bostonCoordinates.longitude, bostonCoordinates.latitude] + }) + } + ]; + + const result = await geocodeStreetPoint(sampleCredentialsV2, { + searchText: 'boston', + country: 'US' + }); + + expect(result).toEqual(bostonCoordinates); + + expect(executeSQL).toHaveBeenCalledWith({ + credentials: sampleCredentialsV2, + query: + "SELECT ST_AsGeoJSON(cdb_geocode_street_point('boston', '', '', 'US')) AS geometry", + opts: {} + }); + }); + + test('uses ldsDecode for V3 credentials', async () => { + global.ldsGeocodeMockResult = [bostonCoordinates]; + const result = await geocodeStreetPoint(sampleCredentialsV3, { + searchText: 'boston' + }); + + expect(result).toEqual(bostonCoordinates); + + expect(ldsGeocode).toHaveBeenCalledWith({ + credentials: sampleCredentialsV3, + address: 'boston', + limit: 1, + opts: {} + }); + }); +}); diff --git a/packages/react-widgets/src/hooks/useGeocoderWidgetController.js b/packages/react-widgets/src/hooks/useGeocoderWidgetController.js new file mode 100644 index 000000000..5d2180e97 --- /dev/null +++ b/packages/react-widgets/src/hooks/useGeocoderWidgetController.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { geocodeStreetPoint } from '../models/GeocodingModel'; + +const DEFAULT_COUNTRY = ''; // 'SPAIN', 'USA' + +const setGeocoderResult = (payload) => ({ + type: 'carto/setGeocoderResult', + payload +}); + +/** + * Controller for component. + * + * @param {object} props + * @param {Function=} [props.onError] - Function to handle error messages from the widget. + */ +export default function useGeocoderWidgetController(props = {}) { + const credentials = useSelector((state) => state.carto.credentials); + // Component local state and events handling + const [searchText, setSearchText] = useState(''); + const [loading, setIsLoading] = useState(false); + + // Actions dispatched + const dispatch = useDispatch(); + + const handleChange = (e) => { + setSearchText(e.target.value); + }; + + const handleInput = (e) => { + if (e.target.value === '') { + updateMarker(null); + } + }; + + const handleKeyPress = async (e) => { + if (e.keyCode === 13) { + // Force blur to hide virtual keyboards on mobile and search + e.target.blur(); + } + }; + + // Needed to handle keyboard "Done" button on iOS + const handleBlur = async () => { + if (searchText.length) { + handleSearch(); + } + }; + + const handleSearch = async () => { + if (!credentials) { + return; + } + try { + setIsLoading(true); + const result = await geocodeStreetPoint(credentials, { + searchText, + country: DEFAULT_COUNTRY + }); + if (result) { + updateMarker(result); + } + } catch (e) { + handleGeocodeError(e); + } finally { + setIsLoading(false); + } + }; + + const updateMarker = (result) => { + dispatch(setGeocoderResult(result)); + }; + + const handleGeocodeError = (error) => { + if (props.onError) { + props.onError(error); + } + }; + + return { + searchText, + loading, + handleChange, + handleInput, + handleKeyPress, + handleBlur + }; +} diff --git a/packages/react-widgets/src/index.d.ts b/packages/react-widgets/src/index.d.ts index 1456c11f8..5a2bf46c4 100644 --- a/packages/react-widgets/src/index.d.ts +++ b/packages/react-widgets/src/index.d.ts @@ -17,4 +17,5 @@ export { export { default as TimeSeriesWidget } from './widgets/TimeSeriesWidget'; export { default as useSourceFilters } from './hooks/useSourceFilters'; export { default as FeatureSelectionWidget } from './widgets/FeatureSelectionWidget'; -export { default as FeatureSelectionLayer } from './layers/FeatureSelectionLayer'; \ No newline at end of file +export { default as FeatureSelectionLayer } from './layers/FeatureSelectionLayer'; +export { default as useGeocoderWidgetController } from './hooks/useGeocoderWidgetController'; \ No newline at end of file diff --git a/packages/react-widgets/src/index.js b/packages/react-widgets/src/index.js index 516f94cd1..7d511da50 100644 --- a/packages/react-widgets/src/index.js +++ b/packages/react-widgets/src/index.js @@ -18,3 +18,4 @@ export { } from './models'; export { default as useSourceFilters } from './hooks/useSourceFilters'; export { default as FeatureSelectionLayer } from './layers/FeatureSelectionLayer'; +export { default as useGeocoderWidgetController } from './hooks/useGeocoderWidgetController'; diff --git a/packages/react-widgets/src/models/GeocodingModel.js b/packages/react-widgets/src/models/GeocodingModel.js index 0c22c48e3..2d901a0c3 100644 --- a/packages/react-widgets/src/models/GeocodingModel.js +++ b/packages/react-widgets/src/models/GeocodingModel.js @@ -1,15 +1,47 @@ // Geocoding / Data Services https://carto.com/developers/data-services-api/reference/ -import { executeSQL } from '@carto/react-api'; +import { executeSQL, ldsGeocode } from '@carto/react-api'; +import { API_VERSIONS } from '@deck.gl/carto'; /** - * Street-Level Geocoder - * @param {*} credentials - * @param {*} { searchText, city, state, country } all optional but searchText + * Street-Level Geocoder. * + * Geocode street given as address and optionally country into geo location (latitude, longitude). + * Only first result is returned. * + * @param {object} credentials + * @param { object } props + * @param { string } props.searchText - searched address to be executed + * @param { string= } props.country - optional, limit search scope to country as ISO-3166 alpha-2 code, example, ES, DE + * @param { string= } props.state - optional, limit search scope to state (only V2 api) + * @param { string= } props.city - optional, limit search scope to city (only V2 api) */ -export const geocodeStreetPoint = async ( +export const geocodeStreetPoint = async (credentials, props, opts = {}) => { + const { searchText, city, state, country } = props; + if (credentials.apiVersion === API_VERSIONS.V3) { + return geocodeStreetPointLds(credentials, { searchText, country }, opts); + } + + return geocodeStreetPointSql(credentials, { searchText, city, state, country }, opts); +}; + +export const geocodeStreetPointLds = async ( + credentials, + { searchText, country }, + opts = {} +) => { + const results = await ldsGeocode({ + credentials, + address: searchText, + country, + limit: 1, + opts + }); + + return results?.[0]; +}; + +export const geocodeStreetPointSql = async ( credentials, { searchText, city, state, country }, opts = {} diff --git a/packages/react-widgets/src/widgets/GeocoderWidget.js b/packages/react-widgets/src/widgets/GeocoderWidget.js index 255534a54..9cd36d809 100644 --- a/packages/react-widgets/src/widgets/GeocoderWidget.js +++ b/packages/react-widgets/src/widgets/GeocoderWidget.js @@ -1,19 +1,12 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { PropTypes } from 'prop-types'; -import { geocodeStreetPoint } from '../models'; -import { addLayer, selectOAuthCredentials, setViewState } from '@carto/react-redux'; +import { addLayer, setViewState } from '@carto/react-redux'; import { CircularProgress, InputBase, Paper, SvgIcon } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; - -const DEFAULT_COUNTRY = ''; // 'SPAIN', 'USA' - -const setGeocoderResult = (payload) => ({ - type: 'carto/setGeocoderResult', - payload -}); +import useGeocoderWidgetController from '../hooks/useGeocoderWidgetController'; const useStyles = makeStyles((theme) => ({ paperInput: { @@ -54,16 +47,13 @@ const SearchIcon = (args) => ( * @param {Object} [props.className] - Material-UI withStyle class for styling * @param {Function} [props.onError] - Function to handle error messages from the widget. */ -function GeocoderWidget(props) { - const inputRef = useRef(); - const oauthCredentials = useSelector(selectOAuthCredentials); - const globalCredentials = useSelector((state) => state.carto.credentials); - const credentials = oauthCredentials || globalCredentials; - // Component local state and events handling - const [searchText, setSearchText] = useState(''); - const [loading, setIsLoading] = useState(false); - // Actions dispatched +function GeocoderWidget(props = {}) { + const classes = useStyles(); const dispatch = useDispatch(); + const geocoderResult = useSelector((state) => state.carto.geocoderResult); + + const { searchText, loading, handleChange, handleInput, handleKeyPress, handleBlur } = + useGeocoderWidgetController(props); useEffect(() => { // layer to display the geocoded direction marker @@ -74,72 +64,18 @@ function GeocoderWidget(props) { ); }, [dispatch]); - const classes = useStyles(); - - const handleChange = (e) => { - setSearchText(e.target.value); - }; - - const handleInput = (e) => { - if (e.target.value === '') { - updateMarker(null); - } - }; - - const handleKeyPress = async (e) => { - if (e.keyCode === 13) { - // Force blur to hide virtual keyboards on mobile and search - e.target.blur(); - } - }; - - // Needed to handle keyboard "Done" button on iOS - const handleBlur = async () => { - if (searchText.length) { - handleSearch(); - } - }; - - const handleSearch = async () => { - if (credentials) { - try { - setIsLoading(true); - const result = await geocodeStreetPoint(credentials, { - searchText, - country: DEFAULT_COUNTRY - }); - if (result) { - zoomToResult(result); - updateMarker(result); - } - } catch (e) { - handleGeocodeError(e); - } finally { - setIsLoading(false); - } - } - }; - - const zoomToResult = (result) => { - dispatch( - setViewState({ - longitude: result.longitude, - latitude: result.latitude, - zoom: 16, - transitionDuration: 500 - }) - ); - }; - - const updateMarker = (result) => { - dispatch(setGeocoderResult(result)); - }; - - const handleGeocodeError = (error) => { - if (props.onError) { - props.onError(error); + useEffect(() => { + if (geocoderResult) { + dispatch( + setViewState({ + longitude: geocoderResult.longitude, + latitude: geocoderResult.latitude, + zoom: 16, + transitionDuration: 500 + }) + ); } - }; + }, [geocoderResult, dispatch]); return ( @@ -148,10 +84,10 @@ function GeocoderWidget(props) { ) : ( )} +